From 3d5e7037b550d832cf7cd3186d6534c6b7a9e874 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:40:01 +0900 Subject: [PATCH 1/3] docs(remote): plan layered workspace carry from PR 3458 --- .../260912_remote_workspace_carry/000_plan.md | 37 ++++++++ .../010_protocol.md | 38 +++++++++ .../020_executor_runtime.md | 84 +++++++++++++++++++ .../030_integration.md | 69 +++++++++++++++ .../040_hosted_validation.md | 20 +++++ 5 files changed, 248 insertions(+) create mode 100644 devlog/_plan/260912_remote_workspace_carry/000_plan.md create mode 100644 devlog/_plan/260912_remote_workspace_carry/010_protocol.md create mode 100644 devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md create mode 100644 devlog/_plan/260912_remote_workspace_carry/030_integration.md create mode 100644 devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md diff --git a/devlog/_plan/260912_remote_workspace_carry/000_plan.md b/devlog/_plan/260912_remote_workspace_carry/000_plan.md new file mode 100644 index 0000000000..7edf86510b --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/000_plan.md @@ -0,0 +1,37 @@ +# Remote Workspace carry roadmap + +Remote Hub routes model requests to credentials on another computer. Remote Workspace adds an opt-in executor that owns approved project files while model sessions remain on the Hub. This unit carries the public foundation in PR #3458 into current dev as a dependency-ordered manual PR chain. It does not declare a production rollout or close the original draft. + +## Loop specification + +Satisfy-spec, triggered by the authorized remote carry lane. Goal: reviewable protocol, executor/runtime, then dashboard integration with author attribution. Non-goals: merge, release, deployment, live pairing or remote operations, user runtime changes. Local suites of every size, builds, typechecks and installation are NOT RUN by instruction. Existing workflows and branch protection remain unchanged; hosted final-tip evidence is required and any unavailable native confinement job is an explicit acceptance gap. + +Verifier: source/diff inspection in each cycle; existing GitHub hosted CI at the final cumulative SHA, plus explicit negative-case coverage. No local suite result is claimed. Stop condition: all scoped deltas audited, published, final evidence captured; unresolved security review or unavailable evidence remains an open acceptance item, never an invented success. Artifacts: this unit and ignored task scratch for sensitive review and execution receipts. Escalation: actual credential/tool gates; no added access, role setup or purchases. Main implements; independent subagents only review. No user token, time or agent-count limit. Existing tool/account scope only. + +## Source and architecture + +Source PR #3458: ba6f822cae53fcc4c91575a4c78f86f9944b6644. Original author: Ingwannu . Latest author comment supersedes earlier failed-job notes; those old results are not evidence for this carry. + +Existing owners: src/remote for provider gateway identity, src/server for admission and management dispatch, src/cli for capabilities, gui/src for navigation, structure/manifest.json for ownership, scripts/test-layout/layout.json for tests. Reuse the original bounded workspace module instead of replacing the Remote Hub. No-op/config-only cannot provide executor tools; original implementation is the carry source. + +## Work phases + +0. Roadmap only: write and independently audit every decade document before production edits. +1. Protocol/identity/permissions: inert codecs, signed handshake, capability contracts and bounded framing. No server route or import/startup timer, process or listener activation. Explicit reassembly calls may own expiry timers. +2. Executor/runtime: file roots, command runner, hub/device state, encrypted transport and runtime bridges. No server/UI activation. Existing helper behavior remains fail-closed. +3. Integration: opt-in Hub admission, authenticated dashboard management, CLI and navigation. Preserve current server decomposition and current test layout. +4. Final hosted validation: final cumulative head only; repair actual final-head failures in separate cycles as needed. Capture chain order, exact head, CI runs and remaining acceptance in ignored handoff. + +Every stage revalidates the pinned source map. New code follows the original file bodies at the immutable source; adaptations are specified in decade documents and amended before execution. Source bodies are linked rather than duplicated into a 15k-line planning copy. + +## Process limitations + +The exposed spawn API does not support native architect role selection. User-authorized inherited design review and reflection satisfy the requested review method; native architect selection is not claimed. Session binding is verified, hooksVerified is false. These gaps are recorded separately from product verification. + +## Acceptance still outside the foundation + +Interactive PTY streaming, central hosted relay, signed Windows artifacts, safe macOS command containment, and real three-computer acceptance remain follow-up work explicitly excluded by the original foundation. Required maintainer security/architecture review stays open. Original PR is not closed by a carry draft. + +## Roadmap lock + +The roadmap passed independent design reflection for the bounded foundation and separate A review after correcting existing-file classifications and evidence reachability. Phase 2 must resolve concrete connection dependencies and required secret-store primitives before implementation. Explicit adaptation paragraphs override whole-body carry defaults. This cycle changes documentation only; product behavior and CI are not claimed verified. Next direction: execute phase 1, then revalidate phase 2 against the resulting tree. diff --git a/devlog/_plan/260912_remote_workspace_carry/010_protocol.md b/devlog/_plan/260912_remote_workspace_carry/010_protocol.md new file mode 100644 index 0000000000..2d89e4eba8 --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/010_protocol.md @@ -0,0 +1,38 @@ +# Phase 1: protocol + +Depends on: phase 0. Source: `ba6f822cae53fcc4c91575a4c78f86f9944b6644`. Main owns implementation; tests execute only on hosted CI. + +## Exact file map + +| Change | Source | Destination | +| --- | --- | --- | +| NEW | [src/remote-control/protocol.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/protocol.ts) | `src/remote-control/protocol.ts` | +| NEW | [src/remote-control/crypto.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/crypto.ts) | `src/remote-control/crypto.ts` | +| NEW | [src/remote-control/host.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/host.ts) | `src/remote-control/host.ts` | +| NEW | [src/remote-control/relay.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/relay.ts) | `src/remote-control/relay.ts` | +| NEW | [src/remote-control/workspace-tools.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-tools.ts) | `src/remote-control/workspace-tools.ts` | +| NEW | [src/remote-control/workspace-agent-protocol.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-agent-protocol.ts) | `src/remote-control/workspace-agent-protocol.ts` | +| NEW | [src/remote-control/workspace-rpc-framing.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-rpc-framing.ts) | `src/remote-control/workspace-rpc-framing.ts` | +| NEW | [src/remote-control/workspace-utf8.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-utf8.ts) | `src/remote-control/workspace-utf8.ts` | +| NEW | [tests/remote-control-prototype.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-control-prototype.test.ts) | `tests/clients/remote-control-prototype.test.ts` | +| NEW | [tests/remote-workspace-rpc-framing.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-rpc-framing.test.ts) | `tests/clients/remote-workspace-rpc-framing.test.ts` | + +## Transformation contract + +NEW files carry the complete immutable source body. For moved tests, rewrite source imports `../src/` to `../../src/`, helper imports `./helpers/` to `../helpers/`, and obsolete fake-server paths to their current fixture owner. Register every new test in both layout.json explicit and test-layout-expected.json. Source-file reads and subprocess fixture paths use tests/helpers/repo-root.ts. Shared existing files take only source PR hunks, preserving all newer dev behavior; resolve conflicts against the named owner before writing. All original adopted implementation receives the coauthor trailer. + +Create a narrow src/remote-control/index.ts exporting only the eight foundation modules. Add structure/remote-workspace.md describing the inactive protocol library; register its ownership of src/remote-control/ in structure/manifest.json and regenerate INDEX using the existing generator. No runtime activation import belongs in this layer. + +Reachable negatives: forged handshake key/signature, replayed sequence, malformed relay/frame length, unknown capability, out-of-order/overlarge RPC fragments. Existing prototype/framing tests cover authentication, replay and bounds; add protocol-only cases where the split omitted coverage. Observe thrown rejection and absence of plaintext delivery in hosted CI. + +## Data and enforcement chain + +Required acceptance (not an established property of the pinned source): identity/capability creation comes from protocol builders and device root approval; serializers carry bounded versioned messages; strict parsers recover them; handshake/coordinator/executor consumers enforce capabilities and roots. GUI only displays public state. Tier: runtime boundary; executing surface: parser/auth/executor code. Known bypass: a process with the operator account can invoke host tools directly. Residual: local operator compromise is outside this process boundary. Wording: bounded remote tools, no claim of host-user isolation. Final layer for commands: OS confinement probe; unavailable means exec is not advertised. + +## Verification and rollback + +Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison and git diff --check observe this change but are not product tests. Existing hosted CI command definitions are inspected before dispatch; final SHA evidence is recorded in phase 4. Revert this layer before its parent; no persistent state migrations are performed by this carry task. + +## Scope decision REMOTE-ARCH-004 + +Retain the eight-module public source and its existing prototype tests: host.ts and relay.ts preserve author protocol coverage. These are inactive adapters. RemoteControlHost may call an explicitly supplied terminal factory only after authenticated traffic; this carry does not supply or connect a production terminal factory. Phase 1 delivers cryptographic identity primitives and protocol contracts, not complete device authorization. diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md new file mode 100644 index 0000000000..550b2a5bb5 --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md @@ -0,0 +1,84 @@ +# Phase 2: executor_runtime + +Depends on: phase 1. Source: `ba6f822cae53fcc4c91575a4c78f86f9944b6644`. Main owns implementation; tests execute only on hosted CI. + +## Exact file map + +| Change | Source | Destination | +| --- | --- | --- | +| NEW | [native/remote-workspace-helper/Cargo.lock](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/Cargo.lock) | `native/remote-workspace-helper/Cargo.lock` | +| NEW | [native/remote-workspace-helper/Cargo.toml](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/Cargo.toml) | `native/remote-workspace-helper/Cargo.toml` | +| NEW | [native/remote-workspace-helper/src/main.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/src/main.rs) | `native/remote-workspace-helper/src/main.rs` | +| NEW | [native/remote-workspace-helper/src/protocol.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/src/protocol.rs) | `native/remote-workspace-helper/src/protocol.rs` | +| NEW | [native/remote-workspace-helper/src/sandbox/macos.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/src/sandbox/macos.rs) | `native/remote-workspace-helper/src/sandbox/macos.rs` | +| NEW | [native/remote-workspace-helper/src/sandbox/mod.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/src/sandbox/mod.rs) | `native/remote-workspace-helper/src/sandbox/mod.rs` | +| NEW | [native/remote-workspace-helper/src/sandbox/windows.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/src/sandbox/windows.rs) | `native/remote-workspace-helper/src/sandbox/windows.rs` | +| NEW | [native/remote-workspace-helper/tests/live_confinement.rs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/native/remote-workspace-helper/tests/live_confinement.rs) | `native/remote-workspace-helper/tests/live_confinement.rs` | +| NEW | [src/cli/remote-workspace.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/cli/remote-workspace.ts) | `src/cli/remote-workspace.ts` | +| MODIFY | [src/remote-control/index.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/index.ts) | `src/remote-control/index.ts` | +| NEW | [src/remote-control/workspace-agent-connection.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-agent-connection.ts) | `src/remote-control/workspace-agent-connection.ts` | +| NEW | [src/remote-control/workspace-claude-runtime.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-claude-runtime.ts) | `src/remote-control/workspace-claude-runtime.ts` | +| NEW | [src/remote-control/workspace-codex-runtime.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-codex-runtime.ts) | `src/remote-control/workspace-codex-runtime.ts` | +| NEW | [src/remote-control/workspace-codex-sandbox.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-codex-sandbox.ts) | `src/remote-control/workspace-codex-sandbox.ts` | +| NEW | [src/remote-control/workspace-command-runner.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-command-runner.ts) | `src/remote-control/workspace-command-runner.ts` | +| NEW | [src/remote-control/workspace-coordinator.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-coordinator.ts) | `src/remote-control/workspace-coordinator.ts` | +| NEW | [src/remote-control/workspace-device.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-device.ts) | `src/remote-control/workspace-device.ts` | +| NEW | [src/remote-control/workspace-executable.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-executable.ts) | `src/remote-control/workspace-executable.ts` | +| NEW | [src/remote-control/workspace-executor.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-executor.ts) | `src/remote-control/workspace-executor.ts` | +| NEW | [src/remote-control/workspace-hub.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-hub.ts) | `src/remote-control/workspace-hub.ts` | +| NEW | [src/remote-control/workspace-pi-runtime.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-pi-runtime.ts) | `src/remote-control/workspace-pi-runtime.ts` | +| NEW | [src/remote-control/workspace-process.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-process.ts) | `src/remote-control/workspace-process.ts` | +| NEW | [src/remote-control/workspace-rpc.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-rpc.ts) | `src/remote-control/workspace-rpc.ts` | +| NEW | [src/remote-control/workspace-runtime.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-runtime.ts) | `src/remote-control/workspace-runtime.ts` | +| NEW | [src/remote-control/workspace-sessions.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-sessions.ts) | `src/remote-control/workspace-sessions.ts` | +| NEW | [src/remote-control/workspace-tool-bridge.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/remote-control/workspace-tool-bridge.ts) | `src/remote-control/workspace-tool-bridge.ts` | +| NEW | [tests/remote-workspace-agent-wire.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-agent-wire.test.ts) | `tests/clients/remote-workspace-agent-wire.test.ts` | +| NEW | [tests/remote-workspace-app-server.integration.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-app-server.integration.test.ts) | `tests/clients/remote-workspace-app-server.integration.test.ts` | +| NEW | [tests/remote-workspace-claude.integration.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-claude.integration.test.ts) | `tests/clients/remote-workspace-claude.integration.test.ts` | +| NEW | [tests/remote-workspace-cli-runtimes.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-cli-runtimes.test.ts) | `tests/clients/remote-workspace-cli-runtimes.test.ts` | +| NEW | [tests/remote-workspace-cli.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-cli.test.ts) | `tests/clients/remote-workspace-cli.test.ts` | +| NEW | [tests/remote-workspace-codex-runtime.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-codex-runtime.test.ts) | `tests/clients/remote-workspace-codex-runtime.test.ts` | +| NEW | [tests/remote-workspace-command-runner.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-command-runner.test.ts) | `tests/clients/remote-workspace-command-runner.test.ts` | +| NEW | [tests/remote-workspace-device.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-device.test.ts) | `tests/clients/remote-workspace-device.test.ts` | +| NEW | [tests/remote-workspace-hub.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-hub.test.ts) | `tests/clients/remote-workspace-hub.test.ts` | +| NEW | [tests/remote-workspace-linux-confinement.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-linux-confinement.test.ts) | `tests/clients/remote-workspace-linux-confinement.test.ts` | +| NEW | [tests/remote-workspace-platform.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-platform.test.ts) | `tests/clients/remote-workspace-platform.test.ts` | +| NEW | [tests/remote-workspace-sessions.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-sessions.test.ts) | `tests/clients/remote-workspace-sessions.test.ts` | +| NEW | [tests/remote-workspace-tool-bridge.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-tool-bridge.test.ts) | `tests/clients/remote-workspace-tool-bridge.test.ts` | +| NEW | [tests/remote-workspace.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace.test.ts) | `tests/clients/remote-workspace.test.ts` | +| MODIFY | [src/lib/windows-atomic-replace.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/lib/windows-atomic-replace.ts) | `src/lib/windows-atomic-replace.ts` | +| MODIFY | [tests/fake-codex-server.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/fake-codex-server.ts) | `tests/fake-codex-server.ts` | +| NEW | [tests/fixtures/fake-claude-stream.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/fixtures/fake-claude-stream.ts) | `tests/fixtures/fake-claude-stream.ts` | +| MODIFY | [package.json](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/package.json) | `package.json` | +| MODIFY | [.gitignore](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/.gitignore) | `.gitignore` | +| MODIFY | [.npmignore](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/.npmignore) | `.npmignore` | + +## Transformation contract + +NEW files carry the complete immutable source body. For moved tests, rewrite source imports `../src/` to `../../src/`, helper imports `./helpers/` to `../helpers/`, and obsolete fake-server paths to their current fixture owner. Register every new test in both layout.json explicit and test-layout-expected.json. Source-file reads and subprocess fixture paths use tests/helpers/repo-root.ts. Shared existing files take only source PR hunks, preserving all newer dev behavior; resolve conflicts against the named owner before writing. All original adopted implementation receives the coauthor trailer. + +Expand the public module exports to the immutable source index (plus any omitted workspace-runtime export only when a consumer requires it). Keep workspace-runtime lazily constructed and unattached to the core. Export win atomic-replace helper only if current owner remains unexported. Preserve newer package metadata and add only native source packaging entries/helper script names. Native source and lockfile are carried; no workflow modification is authorized. + +Reachable negatives: unapproved root and traversal; symlink/hardlink alias; changed helper digest; absent confinement provider; cancelled pending RPC; offline executor with no local fallback; revoked token; expired/used pairing code; rate-limit threshold. Runtime session/fake-provider tests exercise interruption/reconnect and cleanup. Existing final hosted suite is the executable verifier. Native OS confinement not run by existing workflows stays an explicit unmet acceptance item. + +## Data and enforcement chain + +Required acceptance (not an established property of the pinned source): identity/capability creation comes from protocol builders and device root approval; serializers carry bounded versioned messages; strict parsers recover them; handshake/coordinator/executor consumers enforce capabilities and roots. GUI only displays public state. Tier: runtime boundary; executing surface: parser/auth/executor code. Known bypass: a process with the operator account can invoke host tools directly. Residual: local operator compromise is outside this process boundary. Wording: bounded remote tools, no claim of host-user isolation. Final layer for commands: OS confinement probe; unavailable means exec is not advertised. + +## Verification and rollback + +Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison and git diff --check observe this change but are not product tests. Existing hosted CI command definitions are inspected before dispatch; final SHA evidence is recorded in phase 4. Revert this layer before its parent; no persistent state migrations are performed by this carry task. + +## Session contract adaptation + +The executor endpoint receives explicit immutable sessionId, rootId and granted capabilities from the session owner. The agent connection constructs these options from the selected session and accepted capability set. The RPC boundary compares each request with that binding before invoking file or command tools. Test constructors provide the same explicit bindings; negative fixtures cover each mismatch and capability reduction. Keep detailed review evidence in ignored scratch. + +## Design reflection amendments + +REMOTE-ARCH-001/002: Add required per-session capabilities to HubAgentConnection.openSession; initial SessionService.create and ensureRemoteTransport pass their session capability subset. The signed hello carries that subset; accepted.hello.capabilities is copied into endpoint state. Selected root is conveyed by authenticated WSS under the explicitly trusted paired Hub; endpoint snapshots rootId and sessionId and validates locally approved root before session acceptance. Encrypted requests must match session/device/root and tool capability. Zero-executor-invocation negatives vary each independently, including an otherwise-approved second root. No opaque untrusted relay is introduced for workspace control frames. + +REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connection availability. Presence may reduce effective connection authority but cannot expand the enrollment grant. Preserve the enrollment set in persisted device state, compute public effective capabilities as its intersection with current connection capabilities, and intersect new presence before acknowledgement. Device advertisement and acknowledgement accept reduced capabilities. Test downgrade, reconnect restoration inside grant, and attempted expansion beyond grant. + +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. diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md new file mode 100644 index 0000000000..5c126f5987 --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md @@ -0,0 +1,69 @@ +# Phase 3: integration + +Depends on: phase 2. Source: `ba6f822cae53fcc4c91575a4c78f86f9944b6644`. Main owns implementation; tests execute only on hosted CI. + +## Exact file map + +| Change | Source | Destination | +| --- | --- | --- | +| MODIFY | [docs-site/astro.config.mjs](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/docs-site/astro.config.mjs) | `docs-site/astro.config.mjs` | +| MODIFY | [docs-site/src/content/docs/guides/remote-hub.md](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/docs-site/src/content/docs/guides/remote-hub.md) | `docs-site/src/content/docs/guides/remote-hub.md` | +| NEW | [docs-site/src/content/docs/guides/remote-workspace.md](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/docs-site/src/content/docs/guides/remote-workspace.md) | `docs-site/src/content/docs/guides/remote-workspace.md` | +| MODIFY | [docs-site/src/content/docs/reference/cli.md](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/docs-site/src/content/docs/reference/cli.md) | `docs-site/src/content/docs/reference/cli.md` | +| MODIFY | [docs-site/src/content/docs/reference/management-api.md](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/docs-site/src/content/docs/reference/management-api.md) | `docs-site/src/content/docs/reference/management-api.md` | +| MODIFY | [gui/src/App.tsx](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/App.tsx) | `gui/src/App.tsx` | +| MODIFY | [gui/src/app-routing.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/app-routing.ts) | `gui/src/app-routing.ts` | +| MODIFY | [gui/src/i18n/de.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/de.ts) | `gui/src/i18n/de.ts` | +| MODIFY | [gui/src/i18n/en.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/en.ts) | `gui/src/i18n/en.ts` | +| MODIFY | [gui/src/i18n/fr.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/fr.ts) | `gui/src/i18n/fr.ts` | +| MODIFY | [gui/src/i18n/ja.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/ja.ts) | `gui/src/i18n/ja.ts` | +| MODIFY | [gui/src/i18n/ko.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/ko.ts) | `gui/src/i18n/ko.ts` | +| MODIFY | [gui/src/i18n/ru.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/ru.ts) | `gui/src/i18n/ru.ts` | +| MODIFY | [gui/src/i18n/tr.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/tr.ts) | `gui/src/i18n/tr.ts` | +| MODIFY | [gui/src/i18n/zh-TW.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/zh-TW.ts) | `gui/src/i18n/zh-TW.ts` | +| MODIFY | [gui/src/i18n/zh.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/i18n/zh.ts) | `gui/src/i18n/zh.ts` | +| NEW | [gui/src/pages/RemoteWorkspace.tsx](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/pages/RemoteWorkspace.tsx) | `gui/src/pages/RemoteWorkspace.tsx` | +| NEW | [gui/src/remote-workspace-command.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/remote-workspace-command.ts) | `gui/src/remote-workspace-command.ts` | +| NEW | [gui/src/styles-remote-workspace.css](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/styles-remote-workspace.css) | `gui/src/styles-remote-workspace.css` | +| MODIFY | [gui/src/styles.css](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/src/styles.css) | `gui/src/styles.css` | +| MODIFY | [gui/tests/fr-localization.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/tests/fr-localization.test.ts) | `gui/tests/fr-localization.test.ts` | +| MODIFY | [gui/tests/locale-parity.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/tests/locale-parity.test.ts) | `gui/tests/locale-parity.test.ts` | +| NEW | [gui/tests/remote-workspace.test.tsx](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/tests/remote-workspace.test.tsx) | `gui/tests/remote-workspace.test.tsx` | +| MODIFY | [gui/tests/sidebar-rows.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/gui/tests/sidebar-rows.test.ts) | `gui/tests/sidebar-rows.test.ts` | +| MODIFY | [src/cli/dispatch.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/cli/dispatch.ts) | `src/cli/dispatch.ts` | +| MODIFY | [src/cli/help.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/cli/help.ts) | `src/cli/help.ts` | +| MODIFY | [src/cli/registry.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/cli/registry.ts) | `src/cli/registry.ts` | +| MODIFY | [src/server/index.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/index.ts) | `src/server/index.ts` | +| MODIFY | [src/server/management-api.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/management-api.ts) | `src/server/management-api.ts` | +| MODIFY | [src/server/management/context.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/management/context.ts) | `src/server/management/context.ts` | +| NEW | [src/server/management/remote-workspace-routes.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/management/remote-workspace-routes.ts) | `src/server/management/remote-workspace-routes.ts` | +| MODIFY | [src/server/management/route-registry.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/management/route-registry.ts) | `src/server/management/route-registry.ts` | +| MODIFY | [src/server/ws-bridge.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/src/server/ws-bridge.ts) | `src/server/ws-bridge.ts` | +| MODIFY | [tests/cli-headless-parity.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/cli-headless-parity.test.ts) | `tests/cli/cli-headless-parity.test.ts` | +| MODIFY | [tests/loopback-listener-integration.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/loopback-listener-integration.test.ts) | `tests/server/loopback-listener-integration.test.ts` | +| NEW | [tests/remote-workspace-management.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-management.test.ts) | `tests/clients/remote-workspace-management.test.ts` | +| NEW | [tests/remote-workspace-server.test.ts](https://github.com/lidge-jun/opencodex/blob/ba6f822cae53fcc4c91575a4c78f86f9944b6644/tests/remote-workspace-server.test.ts) | `tests/clients/remote-workspace-server.test.ts` | + +## Transformation contract + +NEW files carry the complete immutable source body. For moved tests, rewrite source imports `../src/` to `../../src/`, helper imports `./helpers/` to `../helpers/`, and obsolete fake-server paths to their current fixture owner. Register every new test in both layout.json explicit and test-layout-expected.json. Source-file reads and subprocess fixture paths use tests/helpers/repo-root.ts. Shared existing files take only source PR hunks, preserving all newer dev behavior; resolve conflicts against the named owner before writing. All original adopted implementation receives the coauthor trailer. + +Port original src/server/index.ts admission and WebSocket handlers into current owners after reading current decomposition. Keep runtimeRole=hub and no-Origin checks before lazy workspace imports. Browser mutations require gui-session. Pair exchange retains 32KiB bounded read, kernel-peer limiter and one-time device token. Add remote-workspace branch to all WsData consumers (open/message/close) with disconnect cleanup. Shutdown only touches already initialized workspace instances. Never import workspace modules from router.ts, lifecycle.ts or responses/core.ts. + +CLI registration now also needs src/cli/capabilities.ts and generated skills/ocx surface sync. GUI follows current App routing, nav structure and all locale dictionaries; preserve existing browser consent and error handling. User docs distinguish provider gateway Remote Hub from executor Remote Workspace. Current structure ownership docs receive concise source-appropriate updates without copying obsolete numbered docs. + +Reachable negatives: non-hub invocation; Origin-bearing pair/upgrade; missing/malformed/revoked bearer; admin-token attempt at dashboard mutation; excessive pairing body; ten invalid code attempts; unknown session/device/root; offline status and unavailable runtime. Original management/server tests plus new activation-isolation source guard must observe refusals and no optional activation. UI uses deterministic mocked API rendering, no live pairing. Screenshot must be generated/observed by an authorized renderer; absent render evidence is recorded as NOT VERIFIED, never borrowed from original screenshot. + +## Data and enforcement chain + +Required acceptance (not an established property of the pinned source): identity/capability creation comes from protocol builders and device root approval; serializers carry bounded versioned messages; strict parsers recover them; handshake/coordinator/executor consumers enforce capabilities and roots. GUI only displays public state. Tier: runtime boundary; executing surface: parser/auth/executor code. Known bypass: a process with the operator account can invoke host tools directly. Residual: local operator compromise is outside this process boundary. Wording: bounded remote tools, no claim of host-user isolation. Final layer for commands: OS confinement probe; unavailable means exec is not advertised. + +## Verification and rollback + +Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison and git diff --check observe this change but are not product tests. Existing hosted CI command definitions are inspected before dispatch; final SHA evidence is recorded in phase 4. Revert this layer before its parent; no persistent state migrations are performed by this carry task. + +## Explicit activation REMOTE-ARCH-005 + +NEW src/remote-control/workspace-activation.ts exports a side-effect-free guard requiring runtimeRole=hub AND process.env.OCX_REMOTE_WORKSPACE_ENABLED === "1". This guard imports only the config type. Pair and agent branches call it before dynamic import; disabled requests return 404. Management namespace returns a disabled status before importing runtime. Shutdown uses already retained workspace references or initialized-only lazy import only when explicitly enabled; a disabled Hub never creates identity or probes model CLIs. CLI pairing remains explicit Executor-local authorization and never modifies server environment. Document the opt-in variable and require an explicit environment choice to enable the feature. Test disabled Hub, non-Hub with flag, and enabled Hub, with no ambient inheritance in fixtures. + +Existing-file conflicts observed by git apply --check: management-api.ts, management/context.ts and ws-bridge.ts. Port the namespace-dispatch addition into current management handler, append only type/dependency seam fields after current imports, and extend current WebSocket discriminator/handlers without replacing newer fields. The check was text applicability only, not a product test. diff --git a/devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md b/devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md new file mode 100644 index 0000000000..d8a749b72c --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md @@ -0,0 +1,20 @@ +# Phase 4: final hosted validation + +Depends on phase 3. Inspect final chain ancestry and fresh PR heads, base identities and review state. No workflow, branch protection or cancellation changes. Push only with --no-verify and create draft template-complete ordinary PRs. Existing hosted workflows run on final cumulative tip; request their supported all lane through existing dispatch only if applicable. Record workflow event/ref, head SHA, run ID/URL and result. Missing/skipped/cancelled native confinement is not passing evidence. + +No product implementation is planned in this validation phase. If final CI reveals a regression, amend this roadmap with a named repair cycle and source/test map before editing. Keep source-only checks distinct from executable CI. Record original PR #3458 disposition, coauthor trailer, current chain, full commits/heads, outstanding maintainer reviews, and original foundation exclusions in ignored durable handoff. Parent alone decides merge or original closure. + +Acceptance: final hosted typecheck/suite and applicable native confinement outcomes at the same SHA, no fake pass from old source runs; UI render observation at current tree or explicit unresolved gap. No local suite is permitted. No live devices, remote files or deployment are part of verification. DONE requires evidence; otherwise retain unfinished criteria and report the precise missing requirement. + +## Evidence reachability matrix + +| Path | Activation needed | Generic suite meaning | +| --- | --- | --- | +| Codex real runtime | OCX_CODEX_BIN configured in isolated hosted job | Missing env is skipped, not verified | +| Claude real runtime | OCX_CLAUDE_BIN configured | Missing env is skipped, not verified | +| Pi real runtime | OCX_PI_BIN configured | Missing env is skipped, not verified | +| Linux confinement | bwrap + OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 | Early return is not confinement proof | +| Windows native helper | cargo build + live AppContainer probe | Not invoked by current workflow | +| macOS native helper | cargo fail-closed probe/direct-run | Not invoked by current workflow | + +Hosted mock lifecycle tests and source review retain their narrower meaning. Any unavailable path stays in final handoff acceptance, even if aggregate CI is green. No workflow changes or live-machine pairing are authorized. From 71857fac92e5a148b11975aab07f84f04cb288ea Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:43:42 +0900 Subject: [PATCH 2/3] feat(remote): carry inactive workspace protocol foundation Carry the protocol and cryptographic primitives from #3458 into current dev with bounded wire regression coverage and current structure/test ownership. Co-authored-by: Ingwannu --- .../010_protocol.md | 4 + scripts/test-layout/layout.json | 3 + src/remote-control/crypto.ts | 442 ++++++++++++++++++ src/remote-control/host.ts | 175 +++++++ src/remote-control/index.ts | 100 ++++ src/remote-control/protocol.ts | 200 ++++++++ src/remote-control/relay.ts | 162 +++++++ .../workspace-agent-protocol.ts | 246 ++++++++++ src/remote-control/workspace-rpc-framing.ts | 128 +++++ src/remote-control/workspace-tools.ts | 237 ++++++++++ src/remote-control/workspace-utf8.ts | 24 + structure/INDEX.md | 2 + structure/manifest.json | 9 + structure/remote-workspace.md | 11 + .../clients/remote-control-prototype.test.ts | 238 ++++++++++ .../clients/remote-workspace-protocol.test.ts | 45 ++ .../remote-workspace-rpc-framing.test.ts | 33 ++ tests/fixtures/test-layout-expected.json | 3 + 18 files changed, 2062 insertions(+) create mode 100644 src/remote-control/crypto.ts create mode 100644 src/remote-control/host.ts create mode 100644 src/remote-control/index.ts create mode 100644 src/remote-control/protocol.ts create mode 100644 src/remote-control/relay.ts create mode 100644 src/remote-control/workspace-agent-protocol.ts create mode 100644 src/remote-control/workspace-rpc-framing.ts create mode 100644 src/remote-control/workspace-tools.ts create mode 100644 src/remote-control/workspace-utf8.ts create mode 100644 structure/remote-workspace.md create mode 100644 tests/clients/remote-control-prototype.test.ts create mode 100644 tests/clients/remote-workspace-protocol.test.ts create mode 100644 tests/clients/remote-workspace-rpc-framing.test.ts diff --git a/devlog/_plan/260912_remote_workspace_carry/010_protocol.md b/devlog/_plan/260912_remote_workspace_carry/010_protocol.md index 2d89e4eba8..193a2b592b 100644 --- a/devlog/_plan/260912_remote_workspace_carry/010_protocol.md +++ b/devlog/_plan/260912_remote_workspace_carry/010_protocol.md @@ -36,3 +36,7 @@ Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison ## Scope decision REMOTE-ARCH-004 Retain the eight-module public source and its existing prototype tests: host.ts and relay.ts preserve author protocol coverage. These are inactive adapters. RemoteControlHost may call an explicitly supplied terminal factory only after authenticated traffic; this carry does not supply or connect a production terminal factory. Phase 1 delivers cryptographic identity primitives and protocol contracts, not complete device authorization. + +## Phase-1 revalidation + +Previous D: roadmap locked; continue with inactive protocol library. Base HEAD is 3d5e7037b5, source library still absent. Original eight source files and prototype/framing assertions were read; no path drift affects their self-contained dependency closure. New protocol contract tests will exercise agent codec rejection and UTF-8 byte boundaries directly. Existing clients test domain and both manifest registration maps confirmed. Structure generator is scripts/structure-ssot.ts --fix; it writes INDEX from the manifest and is permitted documentation generation, not a product build or test suite. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ecbbf5862a..fa89d735b8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1051,6 +1051,9 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-control-prototype.test.ts": "clients", + "remote-workspace-protocol.test.ts": "clients", + "remote-workspace-rpc-framing.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", diff --git a/src/remote-control/crypto.ts b/src/remote-control/crypto.ts new file mode 100644 index 0000000000..830340078f --- /dev/null +++ b/src/remote-control/crypto.ts @@ -0,0 +1,442 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createPrivateKey, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + sign, + verify, + type KeyObject, +} from "node:crypto"; +import { + REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES, + REMOTE_CONTROL_PROTOCOL_VERSION, + isRemoteControlCommandProfile, + isRemoteControlUuid, + normalizeRemoteControlCapabilities, + remoteControlUuidBytes, + type RemoteControlCapability, + type RemoteControlClientHello, + type RemoteControlCommandProfile, + type RemoteControlHostHello, +} from "./protocol"; + +const HELLO_MAX_BYTES = 16 * 1024; +const NONCE_BYTES = 32; +const GCM_TAG_BYTES = 16; +const COUNTER_BYTES = 8; +const MAX_COUNTER = 0xffff_ffff_ffff_ffffn; +const MAX_ENCRYPTED_PLAINTEXT_BYTES = REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - GCM_TAG_BYTES - COUNTER_BYTES; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +export interface RemoteControlIdentityKeyPair { + publicKey: string; + privateKey: string; +} + +export interface CreateRemoteControlClientHandshakeOptions { + sessionId: string; + deviceId: string; + commandProfile: RemoteControlCommandProfile; + capabilities: readonly RemoteControlCapability[]; + accountPrivateKey: string; +} + +export interface AcceptRemoteControlClientHelloOptions { + expectedSessionId: string; + expectedDeviceId: string; + accountPublicKey: string; + devicePrivateKey: string; + allowedCapabilities: readonly RemoteControlCapability[]; +} + +function encode(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function decode(value: unknown, label: string, maxBytes: number): Buffer { + if (typeof value !== "string" || !BASE64URL_PATTERN.test(value) || value.length > Math.ceil(maxBytes * 4 / 3) + 4) { + throw new Error(`invalid ${label}`); + } + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength === 0 || decoded.byteLength > maxBytes || encode(decoded) !== value) throw new Error(`invalid ${label}`); + return decoded; +} + +function privateSigningKey(value: string): KeyObject { + const key = createPrivateKey({ key: decode(value, "remote control private key", 256), format: "der", type: "pkcs8" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote control private key must be Ed25519"); + return key; +} + +function publicSigningKey(value: string): KeyObject { + const key = createPublicKey({ key: decode(value, "remote control public key", 256), format: "der", type: "spki" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote control public key must be Ed25519"); + return key; +} + +function publicEphemeralKey(value: string): KeyObject { + const key = createPublicKey({ key: decode(value, "remote control ephemeral key", 256), format: "der", type: "spki" }); + if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") { + throw new Error("remote control ephemeral key must use P-256"); + } + return key; +} + +function lengthPrefixed(value: Uint8Array): Buffer { + if (value.byteLength > 0xffff) throw new Error("remote control transcript field is too large"); + const length = Buffer.alloc(2); + length.writeUInt16BE(value.byteLength); + return Buffer.concat([length, Buffer.from(value)]); +} + +function capabilityTranscript(capabilities: readonly RemoteControlCapability[]): Buffer { + const normalized = normalizeRemoteControlCapabilities([...capabilities]); + return Buffer.concat([ + Buffer.from([normalized.length]), + ...normalized.map(capability => lengthPrefixed(Buffer.from(capability, "utf8"))), + ]); +} + +function clientTranscript(hello: Omit): Buffer { + return Buffer.concat([ + Buffer.from("opencodex-remote-control-client-v1\0", "utf8"), + Buffer.from([hello.version]), + Buffer.from(remoteControlUuidBytes(hello.sessionId)), + Buffer.from(remoteControlUuidBytes(hello.deviceId)), + lengthPrefixed(Buffer.from(hello.commandProfile, "utf8")), + capabilityTranscript(hello.capabilities), + lengthPrefixed(decode(hello.ephemeralPublicKey, "remote control ephemeral key", 256)), + decode(hello.nonce, "remote control client nonce", NONCE_BYTES), + ]); +} + +function hostTranscript( + client: Buffer, + hello: Omit, +): Buffer { + return Buffer.concat([ + Buffer.from("opencodex-remote-control-host-v1\0", "utf8"), + createHash("sha256").update(client).digest(), + capabilityTranscript(hello.capabilities), + lengthPrefixed(decode(hello.ephemeralPublicKey, "remote control ephemeral key", 256)), + decode(hello.nonce, "remote control host nonce", NONCE_BYTES), + ]); +} + +function parseJsonObject(value: Uint8Array, label: string): Record { + if (!(value instanceof Uint8Array) || value.byteLength === 0 || value.byteLength > HELLO_MAX_BYTES) { + throw new Error(`invalid ${label}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error(`invalid ${label}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`invalid ${label}`); + return parsed as Record; +} + +function validateCommonHello(raw: Record, label: string): { + sessionId: string; + deviceId: string; + capabilities: RemoteControlCapability[]; + ephemeralPublicKey: string; + nonce: string; + signature: string; +} { + if (raw.version !== REMOTE_CONTROL_PROTOCOL_VERSION) throw new Error(`unsupported ${label} version`); + if (!isRemoteControlUuid(raw.sessionId) || !isRemoteControlUuid(raw.deviceId)) throw new Error(`invalid ${label} identity`); + const capabilities = normalizeRemoteControlCapabilities(raw.capabilities); + if (typeof raw.ephemeralPublicKey !== "string" || typeof raw.nonce !== "string" || typeof raw.signature !== "string") { + throw new Error(`invalid ${label} cryptographic fields`); + } + const ephemeralPublicKey = raw.ephemeralPublicKey; + const encodedNonce = raw.nonce; + const encodedSignature = raw.signature; + decode(ephemeralPublicKey, `${label} ephemeral key`, 256); + const nonce = decode(encodedNonce, `${label} nonce`, NONCE_BYTES); + if (nonce.byteLength !== NONCE_BYTES) throw new Error(`invalid ${label} nonce`); + const signature = decode(encodedSignature, `${label} signature`, 128); + if (signature.byteLength !== 64) throw new Error(`invalid ${label} signature`); + return { + sessionId: raw.sessionId, + deviceId: raw.deviceId, + capabilities, + ephemeralPublicKey, + nonce: encodedNonce, + signature: encodedSignature, + }; +} + +export function parseRemoteControlClientHello(value: Uint8Array): RemoteControlClientHello { + const raw = parseJsonObject(value, "remote control client hello"); + const common = validateCommonHello(raw, "remote control client hello"); + if (!isRemoteControlCommandProfile(raw.commandProfile)) throw new Error("invalid remote control command profile"); + return { + version: REMOTE_CONTROL_PROTOCOL_VERSION, + ...common, + commandProfile: raw.commandProfile, + }; +} + +export function parseRemoteControlHostHello(value: Uint8Array): RemoteControlHostHello { + const raw = parseJsonObject(value, "remote control host hello"); + return { version: REMOTE_CONTROL_PROTOCOL_VERSION, ...validateCommonHello(raw, "remote control host hello") }; +} + +export function serializeRemoteControlHello(value: RemoteControlClientHello | RemoteControlHostHello): Uint8Array { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + if (encoded.byteLength > HELLO_MAX_BYTES) throw new Error("remote control hello is too large"); + return encoded; +} + +export function generateRemoteControlIdentityKeyPair(): RemoteControlIdentityKeyPair { + const pair = generateKeyPairSync("ed25519"); + return { + publicKey: encode(pair.publicKey.export({ format: "der", type: "spki" })), + privateKey: encode(pair.privateKey.export({ format: "der", type: "pkcs8" })), + }; +} + +function nonce(prefix: Buffer, counter: bigint): Buffer { + const result = Buffer.alloc(12); + prefix.copy(result, 0); + result.writeBigUInt64BE(counter, 4); + return result; +} + +function frameAad(sessionId: string, direction: 0 | 1, counter: bigint): Buffer { + const counterBytes = Buffer.alloc(COUNTER_BYTES); + counterBytes.writeBigUInt64BE(counter); + return Buffer.concat([ + Buffer.from("opencodex-remote-control-frame-v1\0", "utf8"), + Buffer.from(remoteControlUuidBytes(sessionId)), + Buffer.from([direction]), + counterBytes, + ]); +} + +export class RemoteControlCipher { + private sendCounter = 0n; + private receiveCounter = 0n; + private destroyed = false; + + constructor( + private readonly sessionId: string, + private readonly sendDirection: 0 | 1, + private readonly sendKey: Buffer, + private readonly receiveKey: Buffer, + private readonly sendNoncePrefix: Buffer, + private readonly receiveNoncePrefix: Buffer, + ) {} + + encrypt(value: Uint8Array): Uint8Array { + if (this.destroyed) throw new Error("remote control cipher is closed"); + if (!(value instanceof Uint8Array) || value.byteLength > MAX_ENCRYPTED_PLAINTEXT_BYTES) { + throw new Error("remote control encrypted payload is too large"); + } + if (this.sendCounter > MAX_COUNTER) throw new Error("remote control send counter exhausted"); + const counter = this.sendCounter; + const cipher = createCipheriv("aes-256-gcm", this.sendKey, nonce(this.sendNoncePrefix, counter), { authTagLength: GCM_TAG_BYTES }); + cipher.setAAD(frameAad(this.sessionId, this.sendDirection, counter)); + const ciphertext = Buffer.concat([cipher.update(value), cipher.final()]); + const counterBytes = Buffer.alloc(COUNTER_BYTES); + counterBytes.writeBigUInt64BE(counter); + this.sendCounter += 1n; + return Buffer.concat([counterBytes, ciphertext, cipher.getAuthTag()]); + } + + decrypt(value: Uint8Array): Uint8Array { + if (this.destroyed) throw new Error("remote control cipher is closed"); + if ( + !(value instanceof Uint8Array) + || value.byteLength < COUNTER_BYTES + GCM_TAG_BYTES + || value.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES + ) throw new Error("invalid remote control encrypted frame"); + const bytes = Buffer.from(value); + const counter = bytes.readBigUInt64BE(0); + if (counter !== this.receiveCounter) throw new Error("replayed or out-of-order remote control frame"); + const ciphertextEnd = bytes.byteLength - GCM_TAG_BYTES; + const decipher = createDecipheriv( + "aes-256-gcm", + this.receiveKey, + nonce(this.receiveNoncePrefix, counter), + { authTagLength: GCM_TAG_BYTES }, + ); + decipher.setAAD(frameAad(this.sessionId, this.sendDirection === 0 ? 1 : 0, counter)); + decipher.setAuthTag(bytes.subarray(ciphertextEnd)); + const plaintext = Buffer.concat([decipher.update(bytes.subarray(COUNTER_BYTES, ciphertextEnd)), decipher.final()]); + this.receiveCounter += 1n; + return plaintext; + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.sendKey.fill(0); + this.receiveKey.fill(0); + this.sendNoncePrefix.fill(0); + this.receiveNoncePrefix.fill(0); + } +} + +function deriveCipher(options: { + sessionId: string; + shared: Buffer; + clientNonce: Buffer; + hostNonce: Buffer; + role: "client" | "host"; +}): RemoteControlCipher { + const salt = createHash("sha256").update(options.clientNonce).update(options.hostNonce).digest(); + const expand = (info: string, length: number): Buffer => Buffer.from(hkdfSync( + "sha256", + options.shared, + salt, + Buffer.from(info, "utf8"), + length, + )); + const clientToHostKey = expand("opencodex remote control client to host key v1", 32); + const hostToClientKey = expand("opencodex remote control host to client key v1", 32); + const clientToHostNonce = expand("opencodex remote control client to host nonce v1", 4); + const hostToClientNonce = expand("opencodex remote control host to client nonce v1", 4); + options.shared.fill(0); + return options.role === "client" + ? new RemoteControlCipher(options.sessionId, 0, clientToHostKey, hostToClientKey, clientToHostNonce, hostToClientNonce) + : new RemoteControlCipher(options.sessionId, 1, hostToClientKey, clientToHostKey, hostToClientNonce, clientToHostNonce); +} + +export class RemoteControlClientHandshake { + readonly hello: RemoteControlClientHello; + private completed = false; + + private constructor( + hello: RemoteControlClientHello, + private readonly ephemeralPrivateKey: KeyObject, + private readonly transcript: Buffer, + ) { + this.hello = hello; + } + + static create(options: CreateRemoteControlClientHandshakeOptions): RemoteControlClientHandshake { + if (!isRemoteControlUuid(options.sessionId) || !isRemoteControlUuid(options.deviceId)) { + throw new Error("invalid remote control handshake identity"); + } + if (!isRemoteControlCommandProfile(options.commandProfile)) throw new Error("invalid remote control command profile"); + const capabilities = normalizeRemoteControlCapabilities([...options.capabilities]); + const ephemeral = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const unsigned: Omit = { + version: REMOTE_CONTROL_PROTOCOL_VERSION, + sessionId: options.sessionId, + deviceId: options.deviceId, + commandProfile: options.commandProfile, + capabilities, + ephemeralPublicKey: encode(ephemeral.publicKey.export({ format: "der", type: "spki" })), + nonce: encode(randomBytes(NONCE_BYTES)), + }; + const transcript = clientTranscript(unsigned); + const hello: RemoteControlClientHello = { + ...unsigned, + signature: encode(sign(null, transcript, privateSigningKey(options.accountPrivateKey))), + }; + return new RemoteControlClientHandshake(hello, ephemeral.privateKey, transcript); + } + + complete(hostHello: RemoteControlHostHello, devicePublicKey: string): RemoteControlCipher { + if (this.completed) throw new Error("remote control client handshake already completed"); + const validated = parseRemoteControlHostHello(serializeRemoteControlHello(hostHello)); + if (validated.sessionId !== this.hello.sessionId || validated.deviceId !== this.hello.deviceId) { + throw new Error("remote control host hello identity mismatch"); + } + const requested = new Set(this.hello.capabilities); + if (validated.capabilities.some(capability => !requested.has(capability))) { + throw new Error("remote control host granted an unrequested capability"); + } + const unsigned: Omit = { + version: validated.version, + sessionId: validated.sessionId, + deviceId: validated.deviceId, + capabilities: validated.capabilities, + ephemeralPublicKey: validated.ephemeralPublicKey, + nonce: validated.nonce, + }; + if (!verify( + null, + hostTranscript(this.transcript, unsigned), + publicSigningKey(devicePublicKey), + decode(validated.signature, "remote control host signature", 128), + )) throw new Error("remote control host identity verification failed"); + const cipher = deriveCipher({ + sessionId: this.hello.sessionId, + shared: Buffer.from(diffieHellman({ + privateKey: this.ephemeralPrivateKey, + publicKey: publicEphemeralKey(validated.ephemeralPublicKey), + })), + clientNonce: decode(this.hello.nonce, "remote control client nonce", NONCE_BYTES), + hostNonce: decode(validated.nonce, "remote control host nonce", NONCE_BYTES), + role: "client", + }); + this.completed = true; + return cipher; + } +} + +export function acceptRemoteControlClientHello( + clientHello: RemoteControlClientHello, + options: AcceptRemoteControlClientHelloOptions, +): { hello: RemoteControlHostHello; cipher: RemoteControlCipher } { + const validated = parseRemoteControlClientHello(serializeRemoteControlHello(clientHello)); + if (validated.sessionId !== options.expectedSessionId || validated.deviceId !== options.expectedDeviceId) { + throw new Error("remote control client hello identity mismatch"); + } + const allowed = new Set(normalizeRemoteControlCapabilities([...options.allowedCapabilities])); + const capabilities = validated.capabilities.filter(capability => allowed.has(capability)); + const unsignedClient: Omit = { + version: validated.version, + sessionId: validated.sessionId, + deviceId: validated.deviceId, + commandProfile: validated.commandProfile, + capabilities: validated.capabilities, + ephemeralPublicKey: validated.ephemeralPublicKey, + nonce: validated.nonce, + }; + const transcript = clientTranscript(unsignedClient); + if (!verify( + null, + transcript, + publicSigningKey(options.accountPublicKey), + decode(validated.signature, "remote control client signature", 128), + )) throw new Error("remote control account identity verification failed"); + + const ephemeral = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const unsignedHost: Omit = { + version: REMOTE_CONTROL_PROTOCOL_VERSION, + sessionId: validated.sessionId, + deviceId: validated.deviceId, + capabilities, + ephemeralPublicKey: encode(ephemeral.publicKey.export({ format: "der", type: "spki" })), + nonce: encode(randomBytes(NONCE_BYTES)), + }; + const hello: RemoteControlHostHello = { + ...unsignedHost, + signature: encode(sign(null, hostTranscript(transcript, unsignedHost), privateSigningKey(options.devicePrivateKey))), + }; + return { + hello, + cipher: deriveCipher({ + sessionId: validated.sessionId, + shared: Buffer.from(diffieHellman({ + privateKey: ephemeral.privateKey, + publicKey: publicEphemeralKey(validated.ephemeralPublicKey), + })), + clientNonce: decode(validated.nonce, "remote control client nonce", NONCE_BYTES), + hostNonce: decode(hello.nonce, "remote control host nonce", NONCE_BYTES), + role: "host", + }), + }; +} diff --git a/src/remote-control/host.ts b/src/remote-control/host.ts new file mode 100644 index 0000000000..f18166189f --- /dev/null +++ b/src/remote-control/host.ts @@ -0,0 +1,175 @@ +import { + acceptRemoteControlClientHello, + parseRemoteControlClientHello, + serializeRemoteControlHello, + type RemoteControlCipher, +} from "./crypto"; +import { + REMOTE_CONTROL_CAPABILITIES, + REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES, + REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE, + decodeRemoteControlApplicationFrame, + encodeRemoteControlApplicationFrame, + type RemoteControlApplicationFrame, + type RemoteControlCapability, + type RemoteControlCommandProfile, +} from "./protocol"; + +export interface RemoteControlTerminal { + write(value: Uint8Array): void | Promise; + resize(columns: number, rows: number): void | Promise; + close(): void | Promise; +} + +export interface RemoteControlTerminalFactory { + create(options: { + commandProfile: RemoteControlCommandProfile; + onOutput(value: Uint8Array): void; + onExit(code: number): void; + }): RemoteControlTerminal | Promise; +} + +interface HostSession { + commandProfile: RemoteControlCommandProfile; + capabilities: Set; + cipher: RemoteControlCipher; + onCiphertext(value: Uint8Array): void; + terminal?: RemoteControlTerminal; + queue: Promise; + closed: boolean; +} + +export interface RemoteControlHostOptions { + deviceId: string; + devicePrivateKey: string; + accountPublicKey: string; + terminalFactory: RemoteControlTerminalFactory; + allowedCapabilities?: readonly RemoteControlCapability[]; + maxSessions?: number; +} + +/** + * Local execution boundary for the Paseo-style topology. The relay never owns + * this registry or a terminal. A successfully signed hello reserves only a + * session; the first authenticated input/resize frame starts the local process. + */ +export class RemoteControlHost { + private readonly sessions = new Map(); + private readonly allowedCapabilities: readonly RemoteControlCapability[]; + private readonly maxSessions: number; + + constructor(private readonly options: RemoteControlHostOptions) { + this.allowedCapabilities = options.allowedCapabilities ?? REMOTE_CONTROL_CAPABILITIES; + this.maxSessions = options.maxSessions ?? REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE; + if (!Number.isSafeInteger(this.maxSessions) || this.maxSessions < 1) { + throw new Error("invalid remote control host session limit"); + } + } + + open(sessionId: string, clientHelloPayload: Uint8Array, onCiphertext: (value: Uint8Array) => void): Uint8Array { + if (this.sessions.has(sessionId)) throw new Error("remote control host session already exists"); + if (this.sessions.size >= this.maxSessions) throw new Error("remote control host session limit reached"); + const clientHello = parseRemoteControlClientHello(clientHelloPayload); + const accepted = acceptRemoteControlClientHello(clientHello, { + expectedSessionId: sessionId, + expectedDeviceId: this.options.deviceId, + accountPublicKey: this.options.accountPublicKey, + devicePrivateKey: this.options.devicePrivateKey, + allowedCapabilities: this.allowedCapabilities, + }); + this.sessions.set(sessionId, { + commandProfile: clientHello.commandProfile, + capabilities: new Set(accepted.hello.capabilities), + cipher: accepted.cipher, + onCiphertext, + queue: Promise.resolve(), + closed: false, + }); + return serializeRemoteControlHello(accepted.hello); + } + + receive(sessionId: string, encryptedPayload: Uint8Array): Promise { + const session = this.sessions.get(sessionId); + if (!session || session.closed) return Promise.reject(new Error("unknown remote control host session")); + const run = session.queue + .then(() => this.handleAuthenticatedFrame(sessionId, session, encryptedPayload)) + .catch(async error => { + await this.close(sessionId); + throw error; + }); + session.queue = run.catch(() => undefined); + return run; + } + + async close(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + this.sessions.delete(sessionId); + session.closed = true; + session.cipher.destroy(); + if (session.terminal) await session.terminal.close(); + } + + private async handleAuthenticatedFrame( + sessionId: string, + session: HostSession, + encryptedPayload: Uint8Array, + ): Promise { + let frame: RemoteControlApplicationFrame; + try { + frame = decodeRemoteControlApplicationFrame(session.cipher.decrypt(encryptedPayload)); + } catch (error) { + await this.close(sessionId); + throw error; + } + if (frame.kind !== "input" && frame.kind !== "resize") { + await this.close(sessionId); + throw new Error("remote control client sent a host-only terminal frame"); + } + const required: RemoteControlCapability = frame.kind === "input" ? "terminal.input" : "terminal.resize"; + if (!session.capabilities.has(required)) { + await this.close(sessionId); + throw new Error(`remote control capability ${required} was not granted`); + } + if (!session.terminal) { + const terminal = await this.startTerminal(sessionId, session); + if (session.closed) { + await terminal.close(); + throw new Error("remote control host session closed while starting the terminal"); + } + session.terminal = terminal; + } + if (frame.kind === "input") await session.terminal.write(frame.data); + else await session.terminal.resize(frame.columns, frame.rows); + } + + private async startTerminal(sessionId: string, session: HostSession): Promise { + return await this.options.terminalFactory.create({ + commandProfile: session.commandProfile, + onOutput: value => { + if (session.closed || !session.capabilities.has("terminal.output")) return; + const maxChunk = REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 25; + for (let offset = 0; offset < value.byteLength; offset += maxChunk) { + this.emitApplicationFrame(sessionId, session, { kind: "output", data: value.subarray(offset, offset + maxChunk) }); + } + }, + onExit: code => { + if (session.closed) return; + this.emitApplicationFrame(sessionId, session, { kind: "exit", code }); + void this.close(sessionId); + }, + }); + } + + private emitApplicationFrame( + sessionId: string, + session: HostSession, + frame: RemoteControlApplicationFrame, + ): void { + try { + session.onCiphertext(session.cipher.encrypt(encodeRemoteControlApplicationFrame(frame))); + } catch { + void this.close(sessionId); + } + } +} diff --git a/src/remote-control/index.ts b/src/remote-control/index.ts new file mode 100644 index 0000000000..256832a324 --- /dev/null +++ b/src/remote-control/index.ts @@ -0,0 +1,100 @@ +export { + REMOTE_CONTROL_PROTOCOL_VERSION, + REMOTE_CONTROL_RELAY_HEADER_BYTES, + REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES, + REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE, + REMOTE_CONTROL_MAX_BUFFERED_BYTES, + REMOTE_CONTROL_COMMAND_PROFILES, + REMOTE_CONTROL_CAPABILITIES, + isRemoteControlUuid, + remoteControlUuidBytes, + isRemoteControlCommandProfile, + normalizeRemoteControlCapabilities, + encodeRemoteControlRelayFrame, + decodeRemoteControlRelayFrame, + encodeRemoteControlApplicationFrame, + decodeRemoteControlApplicationFrame, +} from "./protocol"; +export type { + RemoteControlCommandProfile, + RemoteControlCapability, + RemoteControlClientHello, + RemoteControlHostHello, + RemoteControlRelayFrameKind, + 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"; +export type { + RemoteControlRelayPeer, + OpaqueRemoteControlRelayOptions, +} from "./relay"; +export { + REMOTE_WORKSPACE_TOOL_NAMESPACE, + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_CAPABILITIES, + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + parseRemoteWorkspaceCapabilities, + remoteWorkspaceToolsForCapabilities, + remoteWorkspaceDynamicToolsForCapabilities, + remoteWorkspaceCapabilityForTool, + isRemoteWorkspaceCapability, + isRemoteWorkspaceToolName, + parseRemoteWorkspaceToolCall, + remoteWorkspaceDeveloperInstructions, + remoteWorkspaceCodexDeveloperInstructions, +} from "./workspace-tools"; +export type { + RemoteWorkspaceCapability, + RemoteWorkspaceToolName, + RemoteWorkspaceDynamicToolFunction, + RemoteWorkspaceDynamicToolNamespace, + 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/protocol.ts b/src/remote-control/protocol.ts new file mode 100644 index 0000000000..d167b9eeab --- /dev/null +++ b/src/remote-control/protocol.ts @@ -0,0 +1,200 @@ +export const REMOTE_CONTROL_PROTOCOL_VERSION = 1 as const; +export const REMOTE_CONTROL_RELAY_HEADER_BYTES = 18; +export const REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES = 64 * 1024; +export const REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE = 4; +export const REMOTE_CONTROL_MAX_BUFFERED_BYTES = 1024 * 1024; + +export const REMOTE_CONTROL_COMMAND_PROFILES = ["shell", "codex", "claude", "pi"] as const; +export type RemoteControlCommandProfile = typeof REMOTE_CONTROL_COMMAND_PROFILES[number]; + +export const REMOTE_CONTROL_CAPABILITIES = [ + "terminal.input", + "terminal.output", + "terminal.resize", + "workspace.read", + "workspace.write", + "workspace.exec", +] as const; +export type RemoteControlCapability = typeof REMOTE_CONTROL_CAPABILITIES[number]; + +export interface RemoteControlClientHello { + version: typeof REMOTE_CONTROL_PROTOCOL_VERSION; + sessionId: string; + deviceId: string; + commandProfile: RemoteControlCommandProfile; + capabilities: RemoteControlCapability[]; + ephemeralPublicKey: string; + nonce: string; + signature: string; +} + +export interface RemoteControlHostHello { + version: typeof REMOTE_CONTROL_PROTOCOL_VERSION; + sessionId: string; + deviceId: string; + capabilities: RemoteControlCapability[]; + ephemeralPublicKey: string; + nonce: string; + signature: string; +} + +export type RemoteControlRelayFrameKind = "open" | "data" | "close"; + +export interface RemoteControlRelayFrame { + kind: RemoteControlRelayFrameKind; + sessionId: string; + payload: Uint8Array; +} + +export type RemoteControlApplicationFrame = + | { kind: "input"; data: Uint8Array } + | { kind: "resize"; columns: number; rows: number } + | { kind: "output"; data: Uint8Array } + | { kind: "exit"; code: number }; + +const RELAY_KIND_TO_BYTE: Record = { + open: 1, + data: 2, + close: 3, +}; +const RELAY_BYTE_TO_KIND = new Map([ + [1, "open"], + [2, "data"], + [3, "close"], +]); + +const APPLICATION_KIND_TO_BYTE = { + input: 1, + resize: 2, + output: 3, + exit: 4, +} as const; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isRemoteControlUuid(value: unknown): value is string { + return typeof value === "string" && UUID_PATTERN.test(value); +} + +export function remoteControlUuidBytes(value: string): Uint8Array { + if (!isRemoteControlUuid(value)) throw new Error("invalid remote control UUID"); + return Uint8Array.from(value.replaceAll("-", "").match(/.{2}/g)!, byte => Number.parseInt(byte, 16)); +} + +function remoteControlUuidString(value: Uint8Array): string { + if (value.byteLength !== 16) throw new Error("invalid remote control UUID bytes"); + const hex = Buffer.from(value).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function isRemoteControlCommandProfile(value: unknown): value is RemoteControlCommandProfile { + return typeof value === "string" && (REMOTE_CONTROL_COMMAND_PROFILES as readonly string[]).includes(value); +} + +export function normalizeRemoteControlCapabilities(value: unknown): RemoteControlCapability[] { + if (!Array.isArray(value) || value.length > REMOTE_CONTROL_CAPABILITIES.length) { + throw new Error("invalid remote control capabilities"); + } + const allowed = new Set(REMOTE_CONTROL_CAPABILITIES); + const result = value.map(capability => { + if (typeof capability !== "string" || !allowed.has(capability)) { + throw new Error("invalid remote control capability"); + } + return capability as RemoteControlCapability; + }); + if (new Set(result).size !== result.length) throw new Error("duplicate remote control capability"); + return result.sort(); +} + +export function encodeRemoteControlRelayFrame(frame: RemoteControlRelayFrame): Uint8Array { + if (!isRemoteControlUuid(frame.sessionId)) throw new Error("invalid relay session ID"); + if (!(frame.payload instanceof Uint8Array) || frame.payload.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES) { + throw new Error("remote control relay payload is too large"); + } + const kind = RELAY_KIND_TO_BYTE[frame.kind]; + if (!kind) throw new Error("invalid remote control relay frame kind"); + const result = new Uint8Array(REMOTE_CONTROL_RELAY_HEADER_BYTES + frame.payload.byteLength); + result[0] = REMOTE_CONTROL_PROTOCOL_VERSION; + result[1] = kind; + result.set(remoteControlUuidBytes(frame.sessionId), 2); + result.set(frame.payload, REMOTE_CONTROL_RELAY_HEADER_BYTES); + return result; +} + +export function decodeRemoteControlRelayFrame(value: Uint8Array): RemoteControlRelayFrame { + if ( + !(value instanceof Uint8Array) + || value.byteLength < REMOTE_CONTROL_RELAY_HEADER_BYTES + || value.byteLength > REMOTE_CONTROL_RELAY_HEADER_BYTES + REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES + ) throw new Error("invalid remote control relay frame length"); + if (value[0] !== REMOTE_CONTROL_PROTOCOL_VERSION) throw new Error("unsupported remote control relay protocol"); + const kind = RELAY_BYTE_TO_KIND.get(value[1]!); + if (!kind) throw new Error("invalid remote control relay frame kind"); + return { + kind, + sessionId: remoteControlUuidString(value.subarray(2, REMOTE_CONTROL_RELAY_HEADER_BYTES)), + payload: value.slice(REMOTE_CONTROL_RELAY_HEADER_BYTES), + }; +} + +function validTerminalDimension(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= 4096; +} + +export function encodeRemoteControlApplicationFrame(frame: RemoteControlApplicationFrame): Uint8Array { + if (frame.kind === "input" || frame.kind === "output") { + if (!(frame.data instanceof Uint8Array) || frame.data.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 25) { + throw new Error("remote control terminal payload is too large"); + } + const result = new Uint8Array(1 + frame.data.byteLength); + result[0] = APPLICATION_KIND_TO_BYTE[frame.kind]; + result.set(frame.data, 1); + return result; + } + if (frame.kind === "resize") { + if (!validTerminalDimension(frame.columns) || !validTerminalDimension(frame.rows)) { + throw new Error("invalid remote control terminal size"); + } + const result = new Uint8Array(5); + result[0] = APPLICATION_KIND_TO_BYTE.resize; + const view = new DataView(result.buffer); + view.setUint16(1, frame.columns, false); + view.setUint16(3, frame.rows, false); + return result; + } + if (!Number.isInteger(frame.code) || frame.code < -0x80000000 || frame.code > 0x7fffffff) { + throw new Error("invalid remote control terminal exit code"); + } + const result = new Uint8Array(5); + result[0] = APPLICATION_KIND_TO_BYTE.exit; + new DataView(result.buffer).setInt32(1, frame.code, false); + return result; +} + +export function decodeRemoteControlApplicationFrame(value: Uint8Array): RemoteControlApplicationFrame { + if (!(value instanceof Uint8Array) || value.byteLength < 1) throw new Error("empty remote control application frame"); + if (value[0] === APPLICATION_KIND_TO_BYTE.input || value[0] === APPLICATION_KIND_TO_BYTE.output) { + if (value.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 24) { + throw new Error("remote control terminal payload is too large"); + } + return { + kind: value[0] === APPLICATION_KIND_TO_BYTE.input ? "input" : "output", + data: value.slice(1), + }; + } + if (value[0] === APPLICATION_KIND_TO_BYTE.resize) { + if (value.byteLength !== 5) throw new Error("invalid remote control resize frame"); + const view = new DataView(value.buffer, value.byteOffset, value.byteLength); + const columns = view.getUint16(1, false); + const rows = view.getUint16(3, false); + if (!validTerminalDimension(columns) || !validTerminalDimension(rows)) { + throw new Error("invalid remote control terminal size"); + } + return { kind: "resize", columns, rows }; + } + if (value[0] === APPLICATION_KIND_TO_BYTE.exit) { + if (value.byteLength !== 5) throw new Error("invalid remote control exit frame"); + return { kind: "exit", code: new DataView(value.buffer, value.byteOffset, value.byteLength).getInt32(1, false) }; + } + throw new Error("unknown remote control application frame"); +} diff --git a/src/remote-control/relay.ts b/src/remote-control/relay.ts new file mode 100644 index 0000000000..0c11ed961d --- /dev/null +++ b/src/remote-control/relay.ts @@ -0,0 +1,162 @@ +import { + REMOTE_CONTROL_MAX_BUFFERED_BYTES, + REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE, + decodeRemoteControlRelayFrame, + encodeRemoteControlRelayFrame, + isRemoteControlUuid, + type RemoteControlRelayFrameKind, +} from "./protocol"; + +export interface RemoteControlRelayPeer { + bufferedAmount(): number; + send(value: Uint8Array): void; + close(code: number, reason: string): void; +} + +interface RelaySession { + deviceId: string; + client: RemoteControlRelayPeer; +} + +export interface OpaqueRemoteControlRelayOptions { + maxBufferedBytes?: number; + maxSessionsPerDevice?: number; +} + +/** + * Routes bounded envelopes without decoding the handshake or application payload. + * Account/session authorization belongs at the HTTP/WebSocket upgrade boundary; + * this class begins only after that control-plane decision has succeeded. + */ +export class OpaqueRemoteControlRelay { + private readonly hosts = new Map(); + private readonly sessions = new Map(); + private readonly maxBufferedBytes: number; + private readonly maxSessionsPerDevice: number; + + constructor(options: OpaqueRemoteControlRelayOptions = {}) { + this.maxBufferedBytes = options.maxBufferedBytes ?? REMOTE_CONTROL_MAX_BUFFERED_BYTES; + this.maxSessionsPerDevice = options.maxSessionsPerDevice ?? REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE; + if (!Number.isSafeInteger(this.maxBufferedBytes) || this.maxBufferedBytes < 1) { + throw new Error("invalid remote control relay buffer limit"); + } + if (!Number.isSafeInteger(this.maxSessionsPerDevice) || this.maxSessionsPerDevice < 1) { + throw new Error("invalid remote control relay session limit"); + } + } + + registerHost(deviceId: string, host: RemoteControlRelayPeer): () => void { + if (!isRemoteControlUuid(deviceId)) throw new Error("invalid remote control device ID"); + const previous = this.hosts.get(deviceId); + if (previous && previous !== host) { + previous.close(1012, "remote control host reconnected"); + this.closeDeviceSessions(deviceId, 1012, "remote control host reconnected"); + } + this.hosts.set(deviceId, host); + return () => { + if (this.hosts.get(deviceId) !== host) return; + this.hosts.delete(deviceId); + this.closeDeviceSessions(deviceId, 1013, "remote control host disconnected"); + }; + } + + attachClient(options: { + sessionId: string; + deviceId: string; + client: RemoteControlRelayPeer; + openPayload: Uint8Array; + }): () => void { + if (this.sessions.has(options.sessionId)) throw new Error("remote control session is already attached"); + const host = this.hosts.get(options.deviceId); + if (!host) throw new Error("remote control host is offline"); + const active = [...this.sessions.values()].filter(session => session.deviceId === options.deviceId).length; + if (active >= this.maxSessionsPerDevice) throw new Error("remote control device session limit reached"); + const session: RelaySession = { deviceId: options.deviceId, client: options.client }; + this.sessions.set(options.sessionId, session); + try { + this.forward(host, "open", options.sessionId, options.openPayload); + } catch (error) { + this.sessions.delete(options.sessionId); + throw error; + } + return () => this.detachClient(options.sessionId, options.client); + } + + receiveFromClient(sessionId: string, client: RemoteControlRelayPeer, payload: Uint8Array): void { + const session = this.sessions.get(sessionId); + if (!session || session.client !== client) throw new Error("unknown remote control client session"); + const host = this.hosts.get(session.deviceId); + if (!host) { + this.sessions.delete(sessionId); + client.close(1013, "remote control host is offline"); + return; + } + try { + this.forward(host, "data", sessionId, payload); + } catch (error) { + this.sessions.delete(sessionId); + client.close(1013, "remote control host is backpressured"); + throw error; + } + } + + receiveFromHost(deviceId: string, host: RemoteControlRelayPeer, encoded: Uint8Array): void { + if (this.hosts.get(deviceId) !== host) throw new Error("unknown remote control host"); + const frame = decodeRemoteControlRelayFrame(encoded); + if (frame.kind === "open") throw new Error("remote control host cannot open browser sessions"); + const session = this.sessions.get(frame.sessionId); + if (!session) return; + if (session.deviceId !== deviceId) { + host.close(1008, "remote control session ownership mismatch"); + return; + } + if (frame.kind === "close") { + this.sessions.delete(frame.sessionId); + session.client.close(1000, "remote control host closed the session"); + return; + } + if (session.client.bufferedAmount() > this.maxBufferedBytes) { + this.sessions.delete(frame.sessionId); + session.client.close(1013, "remote control relay backpressure"); + this.forward(host, "close", frame.sessionId, new Uint8Array()); + return; + } + // Intentionally forward the payload byte-for-byte. Only the endpoints parse it. + session.client.send(frame.payload); + } + + private detachClient(sessionId: string, client: RemoteControlRelayPeer): void { + const session = this.sessions.get(sessionId); + if (!session || session.client !== client) return; + this.sessions.delete(sessionId); + const host = this.hosts.get(session.deviceId); + if (host) { + try { + this.forward(host, "close", sessionId, new Uint8Array()); + } catch { + // The client is already detached; the host transport owns its own close path. + } + } + } + + private closeDeviceSessions(deviceId: string, code: number, reason: string): void { + for (const [sessionId, session] of this.sessions) { + if (session.deviceId !== deviceId) continue; + this.sessions.delete(sessionId); + session.client.close(code, reason); + } + } + + private forward( + target: RemoteControlRelayPeer, + kind: RemoteControlRelayFrameKind, + sessionId: string, + payload: Uint8Array, + ): void { + if (target.bufferedAmount() > this.maxBufferedBytes) { + target.close(1013, "remote control relay backpressure"); + throw new Error("remote control relay target is backpressured"); + } + target.send(encodeRemoteControlRelayFrame({ kind, sessionId, payload })); + } +} diff --git a/src/remote-control/workspace-agent-protocol.ts b/src/remote-control/workspace-agent-protocol.ts new file mode 100644 index 0000000000..da82d495b7 --- /dev/null +++ b/src/remote-control/workspace-agent-protocol.ts @@ -0,0 +1,246 @@ +import { + parseRemoteControlClientHello, + parseRemoteControlHostHello, + serializeRemoteControlHello, +} from "./crypto"; +import { + isRemoteControlCommandProfile, + isRemoteControlUuid, + type RemoteControlClientHello, + type RemoteControlCommandProfile, + type RemoteControlHostHello, +} from "./protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION = 1 as const; +export const REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES = 96 * 1024; + +export type RemoteWorkspaceAgentProfile = Extract; + +export type RemoteWorkspaceHubMessage = + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "presence_ack"; + capabilities: RemoteWorkspaceCapability[]; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "session_open"; + rootId: string; + clientHello: RemoteControlClientHello; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "ciphertext"; + sessionId: string; + payload: Uint8Array; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "session_close"; + sessionId: string; + reason: string; + }; + +export type RemoteWorkspaceAgentMessage = + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "presence"; + capabilities: RemoteWorkspaceCapability[]; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "session_accept"; + sessionId: string; + hostHello: RemoteControlHostHello; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "session_reject"; + sessionId: string; + reason: string; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "ciphertext"; + sessionId: string; + payload: Uint8Array; + } + | { + version: typeof REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION; + type: "heartbeat"; + nonce: string; + }; + +function identifier(value: unknown, label: string): string { + if (typeof value !== "string" || value.length < 1 || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function reason(value: unknown): string { + if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + throw new Error("invalid remote workspace close reason"); + } + return value; +} + +function exactKeys(value: Record, keys: readonly string[]): void { + if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) { + throw new Error("invalid remote workspace control message fields"); + } +} + +function encodedHello(value: RemoteControlClientHello | RemoteControlHostHello): string { + return Buffer.from(serializeRemoteControlHello(value)).toString("base64url"); +} + +function decodedHello(value: unknown, kind: "client"): RemoteControlClientHello; +function decodedHello(value: unknown, kind: "host"): RemoteControlHostHello; +function decodedHello(value: unknown, kind: "client" | "host"): RemoteControlClientHello | RemoteControlHostHello { + if (typeof value !== "string" || value.length < 1 || value.length > 24 * 1024 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error("invalid remote workspace handshake encoding"); + } + const bytes = Buffer.from(value, "base64url"); + return kind === "client" ? parseRemoteControlClientHello(bytes) : parseRemoteControlHostHello(bytes); +} + +function payload(value: unknown): Uint8Array { + if (typeof value !== "string" || value.length < 1 || value.length > 88 * 1024 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error("invalid remote workspace ciphertext encoding"); + } + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength < 24 || decoded.byteLength > 64 * 1024) { + throw new Error("invalid remote workspace ciphertext length"); + } + return decoded; +} + +function parseObject(raw: string | Uint8Array): Record { + const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw); + if (bytes.byteLength < 1 || bytes.byteLength > REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES) { + throw new Error("invalid remote workspace control message length"); + } + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error("invalid remote workspace control message JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("invalid remote workspace control message"); + } + const value = parsed as Record; + if (value.version !== REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION) { + throw new Error("unsupported remote workspace agent protocol"); + } + return value; +} + +export function isRemoteWorkspaceAgentProfile(value: unknown): value is RemoteWorkspaceAgentProfile { + return isRemoteControlCommandProfile(value) && value !== "shell"; +} + +export function serializeRemoteWorkspaceHubMessage(message: RemoteWorkspaceHubMessage): string { + const value: Record = { ...message }; + if (message.type === "session_open") value.clientHello = encodedHello(message.clientHello); + if (message.type === "ciphertext") value.payload = Buffer.from(message.payload).toString("base64url"); + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES) { + throw new Error("remote workspace hub message is too large"); + } + return encoded; +} + +export function serializeRemoteWorkspaceAgentMessage(message: RemoteWorkspaceAgentMessage): string { + const value: Record = { ...message }; + if (message.type === "session_accept") value.hostHello = encodedHello(message.hostHello); + if (message.type === "ciphertext") value.payload = Buffer.from(message.payload).toString("base64url"); + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES) { + throw new Error("remote workspace agent message is too large"); + } + return encoded; +} + +export function parseRemoteWorkspaceHubMessage(raw: string | Uint8Array): RemoteWorkspaceHubMessage { + const value = parseObject(raw); + if (value.type === "presence_ack") { + exactKeys(value, ["version", "type", "capabilities"]); + return { + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence_ack", + capabilities: parseRemoteWorkspaceCapabilities(value.capabilities), + }; + } + if (value.type === "session_open") { + exactKeys(value, ["version", "type", "rootId", "clientHello"]); + const clientHello = decodedHello(value.clientHello, "client"); + if (!isRemoteWorkspaceAgentProfile(clientHello.commandProfile)) { + throw new Error("invalid remote workspace session profile"); + } + return { + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: identifier(value.rootId, "root ID"), + clientHello, + }; + } + if (value.type === "ciphertext") { + exactKeys(value, ["version", "type", "sessionId", "payload"]); + const sessionId = identifier(value.sessionId, "session ID"); + if (!isRemoteControlUuid(sessionId)) throw new Error("invalid remote workspace session ID"); + return { version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, type: "ciphertext", sessionId, payload: payload(value.payload) }; + } + if (value.type === "session_close") { + exactKeys(value, ["version", "type", "sessionId", "reason"]); + const sessionId = identifier(value.sessionId, "session ID"); + if (!isRemoteControlUuid(sessionId)) throw new Error("invalid remote workspace session ID"); + return { version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, type: "session_close", sessionId, reason: reason(value.reason) }; + } + throw new Error("unsupported remote workspace hub message"); +} + +export function parseRemoteWorkspaceAgentMessage(raw: string | Uint8Array): RemoteWorkspaceAgentMessage { + const value = parseObject(raw); + if (value.type === "presence") { + exactKeys(value, ["version", "type", "capabilities"]); + return { + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: parseRemoteWorkspaceCapabilities(value.capabilities), + }; + } + if (value.type === "session_accept") { + exactKeys(value, ["version", "type", "sessionId", "hostHello"]); + const sessionId = identifier(value.sessionId, "session ID"); + if (!isRemoteControlUuid(sessionId)) throw new Error("invalid remote workspace session ID"); + return { + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_accept", + sessionId, + hostHello: decodedHello(value.hostHello, "host"), + }; + } + if (value.type === "session_reject") { + exactKeys(value, ["version", "type", "sessionId", "reason"]); + const sessionId = identifier(value.sessionId, "session ID"); + if (!isRemoteControlUuid(sessionId)) throw new Error("invalid remote workspace session ID"); + return { version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, type: "session_reject", sessionId, reason: reason(value.reason) }; + } + if (value.type === "ciphertext") { + exactKeys(value, ["version", "type", "sessionId", "payload"]); + const sessionId = identifier(value.sessionId, "session ID"); + if (!isRemoteControlUuid(sessionId)) throw new Error("invalid remote workspace session ID"); + return { version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, type: "ciphertext", sessionId, payload: payload(value.payload) }; + } + if (value.type === "heartbeat") { + exactKeys(value, ["version", "type", "nonce"]); + return { version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, type: "heartbeat", nonce: identifier(value.nonce, "heartbeat nonce") }; + } + throw new Error("unsupported remote workspace agent message"); +} diff --git a/src/remote-control/workspace-rpc-framing.ts b/src/remote-control/workspace-rpc-framing.ts new file mode 100644 index 0000000000..3482d005f2 --- /dev/null +++ b/src/remote-control/workspace-rpc-framing.ts @@ -0,0 +1,128 @@ +import { randomBytes } from "node:crypto"; +import { REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES } from "./protocol"; + +const RPC_FRAME_MAGIC = Uint8Array.of(0x4f, 0x43, 0x58, 0x52); // OCXR +const RPC_FRAME_VERSION = 1; +const RPC_FRAME_HEADER_BYTES = 30; +const RPC_FRAME_MESSAGE_ID_BYTES = 16; +const RPC_FRAME_MESSAGE_ID_OFFSET = 6; +const RPC_FRAME_TOTAL_BYTES_OFFSET = 22; +const RPC_FRAME_CHUNK_OFFSET = 26; +const RPC_ENCRYPTION_OVERHEAD_BYTES = 24; +const RPC_MAX_PLAINTEXT_FRAME_BYTES = REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - RPC_ENCRYPTION_OVERHEAD_BYTES; +const RPC_MAX_CHUNK_BYTES = RPC_MAX_PLAINTEXT_FRAME_BYTES - RPC_FRAME_HEADER_BYTES; +const RPC_MAX_INCOMPLETE_MESSAGES = 8; +const RPC_INCOMPLETE_MESSAGE_TTL_MS = 30_000; + +// A 256 KiB UTF-8 write can expand by up to 6x when JSON escapes control bytes. +// Keep the wire contract bounded while still carrying every executor-approved text file. +export const REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES = 2 * 1024 * 1024; + +interface IncompleteMessage { + buffer: Uint8Array; + nextOffset: number; + timer: ReturnType; +} + +function hasMagic(value: Uint8Array): boolean { + return RPC_FRAME_MAGIC.every((byte, index) => value[index] === byte); +} + +function messageKey(frame: Uint8Array): string { + return Buffer.from( + frame.subarray(RPC_FRAME_MESSAGE_ID_OFFSET, RPC_FRAME_MESSAGE_ID_OFFSET + RPC_FRAME_MESSAGE_ID_BYTES), + ).toString("hex"); +} + +function writeHeader(frame: Uint8Array, messageId: Uint8Array, totalBytes: number, offset: number): void { + frame.set(RPC_FRAME_MAGIC, 0); + frame[4] = RPC_FRAME_VERSION; + frame[5] = 0; + frame.set(messageId, RPC_FRAME_MESSAGE_ID_OFFSET); + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + view.setUint32(RPC_FRAME_TOTAL_BYTES_OFFSET, totalBytes); + view.setUint32(RPC_FRAME_CHUNK_OFFSET, offset); +} + +/** + * Splits one logical RPC message without retaining a second full-size copy. + * Each yielded plaintext fits one authenticated relay frame. + */ +export function* frameRemoteWorkspaceRpcMessage(message: Uint8Array): Generator { + if (!(message instanceof Uint8Array) + || message.byteLength < 1 + || message.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("invalid remote workspace RPC message length"); + } + const messageId = randomBytes(RPC_FRAME_MESSAGE_ID_BYTES); + for (let offset = 0; offset < message.byteLength; offset += RPC_MAX_CHUNK_BYTES) { + const end = Math.min(offset + RPC_MAX_CHUNK_BYTES, message.byteLength); + const frame = new Uint8Array(RPC_FRAME_HEADER_BYTES + end - offset); + writeHeader(frame, messageId, message.byteLength, offset); + frame.set(message.subarray(offset, end), RPC_FRAME_HEADER_BYTES); + yield frame; + } +} + +/** Bounded, ordered reassembly for authenticated RPC fragments. */ +export class RemoteWorkspaceRpcReassembler { + private readonly incomplete = new Map(); + + accept(frame: Uint8Array): Uint8Array | null { + if (!(frame instanceof Uint8Array) + || frame.byteLength <= RPC_FRAME_HEADER_BYTES + || frame.byteLength > RPC_MAX_PLAINTEXT_FRAME_BYTES + || !hasMagic(frame) + || frame[4] !== RPC_FRAME_VERSION + || frame[5] !== 0) { + throw new Error("invalid remote workspace RPC frame"); + } + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + const totalBytes = view.getUint32(RPC_FRAME_TOTAL_BYTES_OFFSET); + const offset = view.getUint32(RPC_FRAME_CHUNK_OFFSET); + const payload = frame.subarray(RPC_FRAME_HEADER_BYTES); + if (totalBytes < 1 + || totalBytes > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES + || offset >= totalBytes + || payload.byteLength > totalBytes - offset + || (offset + payload.byteLength < totalBytes && payload.byteLength !== RPC_MAX_CHUNK_BYTES)) { + throw new Error("invalid remote workspace RPC fragment bounds"); + } + if (offset === 0 && payload.byteLength === totalBytes) return payload; + + const key = messageKey(frame); + let state = this.incomplete.get(key); + if (!state) { + if (offset !== 0) throw new Error("remote workspace RPC fragment started out of order"); + if (this.incomplete.size >= RPC_MAX_INCOMPLETE_MESSAGES) { + throw new Error("remote workspace RPC fragment limit reached"); + } + const timer = setTimeout(() => this.drop(key), RPC_INCOMPLETE_MESSAGE_TTL_MS); + timer.unref?.(); + state = { buffer: new Uint8Array(totalBytes), nextOffset: 0, timer }; + this.incomplete.set(key, state); + } else if (state.buffer.byteLength !== totalBytes || state.nextOffset !== offset) { + this.drop(key); + throw new Error("remote workspace RPC fragments are inconsistent or out of order"); + } + + state.buffer.set(payload, offset); + state.nextOffset += payload.byteLength; + if (state.nextOffset !== totalBytes) return null; + this.incomplete.delete(key); + clearTimeout(state.timer); + return state.buffer; + } + + clear(): void { + for (const state of this.incomplete.values()) clearTimeout(state.timer); + this.incomplete.clear(); + } + + private drop(key: string): void { + const state = this.incomplete.get(key); + if (!state) return; + clearTimeout(state.timer); + this.incomplete.delete(key); + } +} diff --git a/src/remote-control/workspace-tools.ts b/src/remote-control/workspace-tools.ts new file mode 100644 index 0000000000..401428e4c3 --- /dev/null +++ b/src/remote-control/workspace-tools.ts @@ -0,0 +1,237 @@ +export const REMOTE_WORKSPACE_TOOL_NAMESPACE = "ocx_remote_workspace" as const; +export const REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES = 256 * 1024; + +export const REMOTE_WORKSPACE_CAPABILITIES = [ + "workspace.read", + "workspace.write", + "workspace.exec", +] as const; + +export type RemoteWorkspaceCapability = typeof REMOTE_WORKSPACE_CAPABILITIES[number]; + +export type RemoteWorkspaceToolName = + | "list_directory" + | "read_file" + | "write_file" + | "exec"; + +export interface RemoteWorkspaceDynamicToolFunction { + type: "function"; + name: RemoteWorkspaceToolName; + description: string; + inputSchema: Record; +} + +export interface RemoteWorkspaceDynamicToolNamespace { + type: "namespace"; + name: typeof REMOTE_WORKSPACE_TOOL_NAMESPACE; + description: string; + tools: RemoteWorkspaceDynamicToolFunction[]; +} + +export interface RemoteWorkspaceToolCallParams { + threadId: string; + turnId: string; + callId: string; + namespace: typeof REMOTE_WORKSPACE_TOOL_NAMESPACE; + tool: RemoteWorkspaceToolName; + arguments: unknown; +} + +export type RemoteWorkspaceToolResult = + | { ok: true; value?: unknown; error?: never } + | { ok: false; error: string; value?: never }; + +const RELATIVE_PATH = { + type: "string", + minLength: 1, + maxLength: 4096, + description: "Path relative to the workspace root. Absolute paths and parent traversal are rejected.", +} as const; + +export const REMOTE_WORKSPACE_DYNAMIC_TOOLS: readonly [RemoteWorkspaceDynamicToolNamespace] = [{ + type: "namespace", + name: REMOTE_WORKSPACE_TOOL_NAMESPACE, + description: "Operate only on the selected remote OpenCodex executor workspace.", + tools: [ + { + type: "function", + name: "list_directory", + description: "List one directory inside the selected remote workspace.", + inputSchema: { + type: "object", + properties: { path: { ...RELATIVE_PATH, default: "." } }, + additionalProperties: false, + }, + }, + { + type: "function", + name: "read_file", + description: "Read a bounded UTF-8 regular file from the selected remote workspace.", + inputSchema: { + type: "object", + properties: { + path: RELATIVE_PATH, + maxBytes: { type: "integer", minimum: 1, maximum: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + }, + required: ["path"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "write_file", + description: "Atomically replace a bounded UTF-8 file in the selected remote workspace.", + inputSchema: { + type: "object", + properties: { + path: RELATIVE_PATH, + content: { type: "string", maxLength: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + expectedSha256: { + type: ["string", "null"], + pattern: "^[0-9a-f]{64}$", + description: "Expected current file hash, or null when the file must not already exist.", + }, + }, + required: ["path", "content", "expectedSha256"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "exec", + description: "Execute an argv vector in a directory inside the selected remote workspace.", + inputSchema: { + type: "object", + properties: { + command: { + type: "array", + minItems: 1, + maxItems: 64, + items: { type: "string", minLength: 1, maxLength: 4096 }, + }, + cwd: { ...RELATIVE_PATH, default: "." }, + timeoutMs: { type: "integer", minimum: 1, maximum: 60_000 }, + }, + required: ["command"], + additionalProperties: false, + }, + }, + ], +}]; + +const TOOL_CAPABILITY: Readonly> = { + list_directory: "workspace.read", + read_file: "workspace.read", + write_file: "workspace.write", + exec: "workspace.exec", +}; + +export function parseRemoteWorkspaceCapabilities( + value: unknown, + fallback: readonly RemoteWorkspaceCapability[] = ["workspace.read", "workspace.write"], +): RemoteWorkspaceCapability[] { + if (value === undefined) return [...fallback]; + if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_CAPABILITIES.length) { + throw new Error("invalid remote workspace capabilities"); + } + const result = new Set(); + for (const capability of value) { + if (!isRemoteWorkspaceCapability(capability)) { + throw new Error("invalid remote workspace capability"); + } + result.add(capability); + } + if (!result.has("workspace.read")) { + throw new Error("remote workspace executors must support workspace.read"); + } + return REMOTE_WORKSPACE_CAPABILITIES.filter(capability => result.has(capability)); +} + +export function remoteWorkspaceToolsForCapabilities( + capabilities: readonly RemoteWorkspaceCapability[], +): RemoteWorkspaceToolName[] { + const allowed = new Set(parseRemoteWorkspaceCapabilities(capabilities)); + return REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools + .filter(tool => allowed.has(TOOL_CAPABILITY[tool.name])) + .map(tool => tool.name); +} + +export function remoteWorkspaceDynamicToolsForCapabilities( + capabilities: readonly RemoteWorkspaceCapability[], +): readonly [RemoteWorkspaceDynamicToolNamespace] { + const names = new Set(remoteWorkspaceToolsForCapabilities(capabilities)); + return [{ + ...REMOTE_WORKSPACE_DYNAMIC_TOOLS[0], + tools: REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => names.has(tool.name)), + }]; +} + +export function remoteWorkspaceCapabilityForTool(tool: RemoteWorkspaceToolName): RemoteWorkspaceCapability { + return TOOL_CAPABILITY[tool]; +} + +const TOOL_NAMES: ReadonlySet = new Set( + REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name), +); + +export function isRemoteWorkspaceCapability(value: unknown): value is RemoteWorkspaceCapability { + return value === "workspace.read" || value === "workspace.write" || value === "workspace.exec"; +} + +export function isRemoteWorkspaceToolName(value: unknown): value is RemoteWorkspaceToolName { + return typeof value === "string" && TOOL_NAMES.has(value); +} + +function boundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value); +} + +export function parseRemoteWorkspaceToolCall(value: unknown): RemoteWorkspaceToolCallParams { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid remote workspace tool call"); + } + const raw = value as Record; + if ( + !boundedIdentifier(raw.threadId) + || !boundedIdentifier(raw.turnId) + || !boundedIdentifier(raw.callId) + || raw.namespace !== REMOTE_WORKSPACE_TOOL_NAMESPACE + || !isRemoteWorkspaceToolName(raw.tool) + ) throw new Error("invalid remote workspace tool call identity"); + return { + threadId: raw.threadId, + turnId: raw.turnId, + callId: raw.callId, + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool: raw.tool, + arguments: raw.arguments, + }; +} + +export function remoteWorkspaceDeveloperInstructions( + deviceName: string, + tools: readonly RemoteWorkspaceToolName[] = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name), +): string { + const safeName = deviceName.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) || "remote executor"; + const allowed = tools.map(tool => `${REMOTE_WORKSPACE_TOOL_NAMESPACE}.${tool}`).join(", "); + return [ + `This thread operates on the OpenCodex remote executor named ${JSON.stringify(safeName)}.`, + `Its available remote tools are: ${allowed}. Use no other tool for filesystem or command work.`, + "The coordinator filesystem is an empty isolation boundary and is not the user's workspace.", + "Never use local shell, local file, or local patch tools for this thread.", + "If a remote tool is unavailable, stop and report that the executor is offline; never fall back locally.", + ].join(" "); +} + +export function remoteWorkspaceCodexDeveloperInstructions( + deviceName: string, + tools: readonly RemoteWorkspaceToolName[] = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name), +): string { + const nestedTools = tools.map(tool => `tools.mcp__${REMOTE_WORKSPACE_TOOL_NAMESPACE}__${tool}`).join(", "); + return [ + remoteWorkspaceDeveloperInstructions(deviceName, tools), + `Current Codex versions expose MCP through the functions.exec code-mode tool. Inside it, call only: ${nestedTools}.`, + "Do not call exec_command, apply_patch, view_image, browser, apps, plugins, or any other nested helper on the Hub.", + ].join(" "); +} diff --git a/src/remote-control/workspace-utf8.ts b/src/remote-control/workspace-utf8.ts new file mode 100644 index 0000000000..d4f22832e5 --- /dev/null +++ b/src/remote-control/workspace-utf8.ts @@ -0,0 +1,24 @@ +import { Buffer } from "node:buffer"; + +export function truncateRemoteWorkspaceUtf8(value: string, maximumBytes: number): string { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + throw new Error("invalid remote workspace UTF-8 limit"); + } + if (Buffer.byteLength(value, "utf8") <= maximumBytes) return value; + let low = 0; + let high = Math.min(value.length, maximumBytes); + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (Buffer.byteLength(value.slice(0, middle), "utf8") <= maximumBytes) low = middle; + else high = middle - 1; + } + if ( + low > 0 + && low < value.length + && value.charCodeAt(low - 1) >= 0xd800 + && value.charCodeAt(low - 1) <= 0xdbff + && value.charCodeAt(low) >= 0xdc00 + && value.charCodeAt(low) <= 0xdfff + ) low -= 1; + return value.slice(0, low); +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 2a310d083c..cb8b9250e5 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -42,6 +42,7 @@ The wire surfaces a client actually talks to. | [`data-planes/images.md`](data-planes/images.md) | Standalone image generation and edit relay. | | [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. | | [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. | +| [`remote-workspace.md`](remote-workspace.md) | Inactive workspace protocol, cryptographic identity primitives, and bounded framing. | ### Tier 4 — Providers and adapters @@ -115,6 +116,7 @@ for it; see [`AGENTS.md`](AGENTS.md). | `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | +| `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | | `src/remote/` | [`runtime.md`](runtime.md) | | `src/responses/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | diff --git a/structure/manifest.json b/structure/manifest.json index df35edc7b8..fd85177820 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -361,6 +361,15 @@ "documents": [ "gui/" ] + }, + { + "path": "remote-workspace.md", + "tier": 3, + "title": "Remote Workspace", + "scope": "Inactive workspace protocol, cryptographic identity primitives, and bounded framing.", + "documents": [ + "src/remote-control/" + ] } ], "grace": { diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md new file mode 100644 index 0000000000..534c649471 --- /dev/null +++ b/structure/remote-workspace.md @@ -0,0 +1,11 @@ +# Remote Workspace protocol + +`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/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/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/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. + +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. diff --git a/tests/clients/remote-control-prototype.test.ts b/tests/clients/remote-control-prototype.test.ts new file mode 100644 index 0000000000..01d9d8232f --- /dev/null +++ b/tests/clients/remote-control-prototype.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + OpaqueRemoteControlRelay, + RemoteControlClientHandshake, + RemoteControlHost, + acceptRemoteControlClientHello, + decodeRemoteControlApplicationFrame, + decodeRemoteControlRelayFrame, + encodeRemoteControlApplicationFrame, + encodeRemoteControlRelayFrame, + generateRemoteControlIdentityKeyPair, + serializeRemoteControlHello, + type RemoteControlRelayPeer, + type RemoteControlTerminal, +} from "../../src/remote-control"; + +class Peer implements RemoteControlRelayPeer { + buffered = 0; + sent: Uint8Array[] = []; + closed: Array<{ code: number; reason: string }> = []; + + bufferedAmount(): number { return this.buffered; } + send(value: Uint8Array): void { this.sent.push(value.slice()); } + close(code: number, reason: string): void { this.closed.push({ code, reason }); } +} + +function handshakeFixture() { + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const sessionId = randomUUID(); + const deviceId = randomUUID(); + const client = RemoteControlClientHandshake.create({ + sessionId, + deviceId, + commandProfile: "codex", + capabilities: ["terminal.input", "terminal.output", "terminal.resize"], + accountPrivateKey: account.privateKey, + }); + return { account, device, sessionId, deviceId, client }; +} + +describe("Paseo-style remote control prototype", () => { + test("authenticates both endpoints, encrypts both directions, and rejects replay", () => { + const fixture = handshakeFixture(); + const accepted = acceptRemoteControlClientHello(fixture.client.hello, { + expectedSessionId: fixture.sessionId, + expectedDeviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + allowedCapabilities: ["terminal.input", "terminal.output", "terminal.resize"], + }); + const clientCipher = fixture.client.complete(accepted.hello, fixture.device.publicKey); + + const first = clientCipher.encrypt(new TextEncoder().encode("private terminal input")); + expect(new TextDecoder().decode(accepted.cipher.decrypt(first))).toBe("private terminal input"); + expect(() => accepted.cipher.decrypt(first)).toThrow("replayed or out-of-order"); + + const authenticated = clientCipher.encrypt(new TextEncoder().encode("authenticated frame")); + const tamperedCiphertext = Uint8Array.from(authenticated); + tamperedCiphertext[tamperedCiphertext.length - 1]! ^= 1; + expect(() => accepted.cipher.decrypt(tamperedCiphertext)).toThrow(); + expect(new TextDecoder().decode(accepted.cipher.decrypt(authenticated))).toBe("authenticated frame"); + + const reply = accepted.cipher.encrypt(new TextEncoder().encode("private terminal output")); + expect(new TextDecoder().decode(clientCipher.decrypt(reply))).toBe("private terminal output"); + + const tampered = { ...fixture.client.hello, commandProfile: "claude" as const }; + expect(() => acceptRemoteControlClientHello(tampered, { + expectedSessionId: fixture.sessionId, + expectedDeviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + allowedCapabilities: ["terminal.input"], + })).toThrow("account identity verification failed"); + }); + + test("does not start the local terminal until the first authenticated application frame", async () => { + const fixture = handshakeFixture(); + const events: string[] = []; + let output: ((value: Uint8Array) => void) | undefined; + const terminal: RemoteControlTerminal = { + write(value) { events.push(`input:${new TextDecoder().decode(value)}`); }, + resize(columns, rows) { events.push(`resize:${columns}x${rows}`); }, + close() { events.push("close"); }, + }; + const encryptedOutput: Uint8Array[] = []; + const host = new RemoteControlHost({ + deviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + terminalFactory: { + create(options) { + events.push(`start:${options.commandProfile}`); + output = options.onOutput; + return terminal; + }, + }, + }); + const hostHelloPayload = host.open( + fixture.sessionId, + serializeRemoteControlHello(fixture.client.hello), + value => encryptedOutput.push(value), + ); + expect(events).toEqual([]); + const clientCipher = fixture.client.complete( + JSON.parse(new TextDecoder().decode(hostHelloPayload)), + fixture.device.publicKey, + ); + await host.receive(fixture.sessionId, clientCipher.encrypt(encodeRemoteControlApplicationFrame({ + kind: "resize", + columns: 120, + rows: 40, + }))); + expect(events).toEqual(["start:codex", "resize:120x40"]); + + output!(new TextEncoder().encode("hello from local PTY")); + expect(encryptedOutput).toHaveLength(1); + const decoded = decodeRemoteControlApplicationFrame(clientCipher.decrypt(encryptedOutput[0]!)); + expect(decoded.kind).toBe("output"); + if (decoded.kind === "output") expect(new TextDecoder().decode(decoded.data)).toBe("hello from local PTY"); + }); + + test("relay routes opaque bytes without parsing terminal plaintext", () => { + const relay = new OpaqueRemoteControlRelay(); + const host = new Peer(); + const client = new Peer(); + const deviceId = randomUUID(); + const sessionId = randomUUID(); + relay.registerHost(deviceId, host); + + const opaqueHello = Uint8Array.from([0, 255, 17, 33, 128]); + relay.attachClient({ sessionId, deviceId, client, openPayload: opaqueHello }); + const open = decodeRemoteControlRelayFrame(host.sent.shift()!); + expect(open.kind).toBe("open"); + expect(open.payload).toEqual(opaqueHello); + + const ciphertext = Uint8Array.from([222, 173, 190, 239, 0, 255]); + relay.receiveFromHost(deviceId, host, encodeRemoteControlRelayFrame({ + kind: "data", + sessionId, + payload: ciphertext, + })); + expect(client.sent).toEqual([ciphertext]); + }); + + test("carries an encrypted terminal round trip through the opaque relay", async () => { + const fixture = handshakeFixture(); + const relay = new OpaqueRemoteControlRelay(); + const hostSocket = new Peer(); + const browserSocket = new Peer(); + let terminalOutput: ((value: Uint8Array) => void) | undefined; + const terminalInput: string[] = []; + const localHost = new RemoteControlHost({ + deviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + terminalFactory: { + create(options) { + terminalOutput = options.onOutput; + return { + write(value) { terminalInput.push(new TextDecoder().decode(value)); }, + resize() {}, + close() {}, + }; + }, + }, + }); + relay.registerHost(fixture.deviceId, hostSocket); + relay.attachClient({ + sessionId: fixture.sessionId, + deviceId: fixture.deviceId, + client: browserSocket, + openPayload: serializeRemoteControlHello(fixture.client.hello), + }); + + const open = decodeRemoteControlRelayFrame(hostSocket.sent.shift()!); + const hostHello = localHost.open(open.sessionId, open.payload, ciphertext => { + relay.receiveFromHost(fixture.deviceId, hostSocket, encodeRemoteControlRelayFrame({ + kind: "data", + sessionId: fixture.sessionId, + payload: ciphertext, + })); + }); + relay.receiveFromHost(fixture.deviceId, hostSocket, encodeRemoteControlRelayFrame({ + kind: "data", + sessionId: fixture.sessionId, + payload: hostHello, + })); + const clientCipher = fixture.client.complete( + JSON.parse(new TextDecoder().decode(browserSocket.sent.shift()!)), + fixture.device.publicKey, + ); + + relay.receiveFromClient( + fixture.sessionId, + browserSocket, + clientCipher.encrypt(encodeRemoteControlApplicationFrame({ + kind: "input", + data: new TextEncoder().encode("codex --help\n"), + })), + ); + const input = decodeRemoteControlRelayFrame(hostSocket.sent.shift()!); + await localHost.receive(input.sessionId, input.payload); + expect(terminalInput).toEqual(["codex --help\n"]); + + terminalOutput!(new TextEncoder().encode("local-only output")); + const output = decodeRemoteControlApplicationFrame(clientCipher.decrypt(browserSocket.sent.shift()!)); + expect(output.kind).toBe("output"); + if (output.kind === "output") expect(new TextDecoder().decode(output.data)).toBe("local-only output"); + }); + + test("enforces device session and backpressure bounds", () => { + const relay = new OpaqueRemoteControlRelay({ maxSessionsPerDevice: 1, maxBufferedBytes: 8 }); + const host = new Peer(); + const deviceId = randomUUID(); + relay.registerHost(deviceId, host); + relay.attachClient({ sessionId: randomUUID(), deviceId, client: new Peer(), openPayload: Uint8Array.of(1) }); + expect(() => relay.attachClient({ + sessionId: randomUUID(), + deviceId, + client: new Peer(), + openPayload: Uint8Array.of(2), + })).toThrow("session limit"); + + const backpressured = new Peer(); + backpressured.buffered = 9; + const secondDevice = randomUUID(); + relay.registerHost(secondDevice, backpressured); + expect(() => relay.attachClient({ + sessionId: randomUUID(), + deviceId: secondDevice, + client: new Peer(), + openPayload: Uint8Array.of(3), + })).toThrow("backpressured"); + expect(backpressured.closed[0]?.code).toBe(1013); + }); +}); diff --git a/tests/clients/remote-workspace-protocol.test.ts b/tests/clients/remote-workspace-protocol.test.ts new file mode 100644 index 0000000000..87a2eacc71 --- /dev/null +++ b/tests/clients/remote-workspace-protocol.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + remoteWorkspaceToolsForCapabilities, + serializeRemoteWorkspaceAgentMessage, + truncateRemoteWorkspaceUtf8, +} from "../../src/remote-control"; + +describe("Remote Workspace protocol contracts", () => { + test("presence carries the explicit read-only capability set", () => { + const encoded = serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read"], + }); + expect(parseRemoteWorkspaceAgentMessage(encoded)).toEqual({ + version: 1, type: "presence", capabilities: ["workspace.read"], + }); + expect(remoteWorkspaceToolsForCapabilities(["workspace.read"])).toEqual([ + "list_directory", "read_file", + ]); + }); + + test("rejects unknown fields, versions and capabilities at the wire boundary", () => { + for (const value of [ + { version: 2, type: "presence", capabilities: ["workspace.read"] }, + { version: 1, type: "presence", capabilities: ["workspace.admin"] }, + { version: 1, type: "presence", capabilities: ["workspace.read"], root: "/" }, + ]) expect(() => parseRemoteWorkspaceAgentMessage(JSON.stringify(value))).toThrow(); + expect(() => parseRemoteWorkspaceHubMessage(JSON.stringify({ + version: 1, type: "ciphertext", sessionId: "not-a-session", payload: "A".repeat(32), + }))).toThrow("session ID"); + }); + + test("refuses oversized control messages before interpreting their fields", () => { + expect(() => parseRemoteWorkspaceAgentMessage(" ".repeat(96 * 1024 + 1))).toThrow("length"); + }); + + test("UTF-8 limits preserve complete scalar values at byte boundaries", () => { + expect(truncateRemoteWorkspaceUtf8("A😀한", 4)).toBe("A"); + expect(truncateRemoteWorkspaceUtf8("A😀한", 5)).toBe("A😀"); + expect(truncateRemoteWorkspaceUtf8("A😀한", 8)).toBe("A😀한"); + expect(truncateRemoteWorkspaceUtf8("한", 0)).toBe(""); + expect(() => truncateRemoteWorkspaceUtf8("x", -1)).toThrow("limit"); + }); +}); diff --git a/tests/clients/remote-workspace-rpc-framing.test.ts b/tests/clients/remote-workspace-rpc-framing.test.ts new file mode 100644 index 0000000000..f13f043c90 --- /dev/null +++ b/tests/clients/remote-workspace-rpc-framing.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { + RemoteWorkspaceRpcReassembler, + frameRemoteWorkspaceRpcMessage, +} from "../../src/remote-control/workspace-rpc-framing"; + +describe("Remote Workspace RPC framing", () => { + test("round-trips a logical message across bounded relay frames", () => { + const message = new TextEncoder().encode("large-rpc\n".repeat(30_000)); + const frames = [...frameRemoteWorkspaceRpcMessage(message)]; + expect(frames.length).toBeGreaterThan(1); + expect(Math.max(...frames.map(frame => frame.byteLength))).toBeLessThanOrEqual(64 * 1024 - 24); + const reassembler = new RemoteWorkspaceRpcReassembler(); + let complete: Uint8Array | null = null; + for (const frame of frames) complete = reassembler.accept(frame); + expect(complete).toEqual(message); + }); + + test("rejects out-of-order fragments and forgets cleared partial messages", () => { + const frames = [...frameRemoteWorkspaceRpcMessage(new Uint8Array(200_000))]; + const reassembler = new RemoteWorkspaceRpcReassembler(); + expect(() => reassembler.accept(frames[1]!)).toThrow("out of order"); + expect(reassembler.accept(frames[0]!)).toBeNull(); + reassembler.clear(); + expect(() => reassembler.accept(frames[1]!)).toThrow("out of order"); + }); + + test("rejects a declared allocation above the logical message limit", () => { + const frame = [...frameRemoteWorkspaceRpcMessage(new Uint8Array(100_000))][0]!.slice(); + new DataView(frame.buffer, frame.byteOffset, frame.byteLength).setUint32(22, 0xffff_ffff); + expect(() => new RemoteWorkspaceRpcReassembler().accept(frame)).toThrow("fragment bounds"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b92e85757c..6e98e236f0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -886,6 +886,9 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-control-prototype.test.ts": "clients", + "remote-workspace-protocol.test.ts": "clients", + "remote-workspace-rpc-framing.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", From 726ddc7fc0704c45299eef785e70624a07303784 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:44:51 +0900 Subject: [PATCH 3/3] test(remote): cover rejected host signatures and pinned identities --- .../clients/remote-control-prototype.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/clients/remote-control-prototype.test.ts b/tests/clients/remote-control-prototype.test.ts index 01d9d8232f..dea420eaf3 100644 --- a/tests/clients/remote-control-prototype.test.ts +++ b/tests/clients/remote-control-prototype.test.ts @@ -41,6 +41,38 @@ function handshakeFixture() { } describe("Paseo-style remote control prototype", () => { + test("rejects a host authenticated with a different pinned device key", () => { + const fixture = handshakeFixture(); + const accepted = acceptRemoteControlClientHello(fixture.client.hello, { + expectedSessionId: fixture.sessionId, + expectedDeviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + allowedCapabilities: ["terminal.input"], + }); + const otherDevice = generateRemoteControlIdentityKeyPair(); + expect(() => fixture.client.complete(accepted.hello, otherDevice.publicKey)) + .toThrow("host identity verification failed"); + accepted.cipher.destroy(); + }); + + test("rejects a valid-length corrupted host signature", () => { + const fixture = handshakeFixture(); + const accepted = acceptRemoteControlClientHello(fixture.client.hello, { + expectedSessionId: fixture.sessionId, + expectedDeviceId: fixture.deviceId, + devicePrivateKey: fixture.device.privateKey, + accountPublicKey: fixture.account.publicKey, + allowedCapabilities: ["terminal.input"], + }); + const signature = Buffer.from(accepted.hello.signature, "base64url"); + signature[0]! ^= 1; + expect(() => fixture.client.complete({ + ...accepted.hello, signature: signature.toString("base64url"), + }, fixture.device.publicKey)).toThrow("host identity verification failed"); + accepted.cipher.destroy(); + }); + test("authenticates both endpoints, encrypts both directions, and rejects replay", () => { const fixture = handshakeFixture(); const accepted = acceptRemoteControlClientHello(fixture.client.hello, {