From 9cab1aebc18d5c516f8d5dfccbbf7d8aebdcdcc2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:48:55 +0900 Subject: [PATCH 01/68] fix(start): report auxiliary listener failures without public-port retries --- .../src/content/docs/guides/remote-hub.md | 2 + scripts/test-layout/layout.json | 1 + src/cli/index.ts | 4 +- src/config.ts | 24 ++++++++ src/server/index.ts | 5 +- src/server/ports.ts | 17 ++++++ structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-start-auxiliary-bind.test.ts | 58 +++++++++++++++++++ tests/config/config-load-degrade.test.ts | 47 ++++++++++++++- tests/fixtures/test-layout-expected.json | 1 + .../loopback-listener-integration.test.ts | 21 +++++-- tests/server/ports.test.ts | 13 ++++- 28 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 tests/cli/cli-start-auxiliary-bind.test.ts diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..9a7dd93195 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,5 @@ For a service rollback, stop the branch service and repair the prior release aga session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** page. It is the sole post-disconnect revocation path. + +If an auxiliary listener cannot bind, startup names `unauthenticatedLoopbackListener` or `hub.managementIngress` and the actual address. Correct that listener or free its address; changing only the public proxy port does not repair a fixed auxiliary port. Malformed hand-edited listener blocks warn and remain disabled while unrelated settings are preserved. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ecbbf5862a..9b229150ee 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -357,6 +357,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", diff --git a/src/cli/index.ts b/src/cli/index.ts index 97a80f32c8..93c963833b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -63,7 +63,7 @@ import { import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch"; -import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; @@ -391,7 +391,7 @@ async function handleStart(options: { block?: boolean } = {}) { scheduleCatalogPrewarm(); break; } catch (err) { - if (!isAddrInUse(err) || attempt >= 2) throw err; + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; if (requestedPort !== undefined) { console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); const hostname = loadConfig().hostname ?? "127.0.0.1"; diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..8209ff794f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1959,6 +1959,26 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { } } +function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const warnings: string[] = []; + if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) { + warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener"); + } + const hub = rawConfigRecord(raw.hub); + if (hub?.managementIngress !== undefined && validated.hub?.managementIngress === undefined) { + warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener"); + } + return warnings; +} + +function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void { + for (const warning of degradedListenerWarnings(rawParsed, validated)) { + console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + /** * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. * Priority is a preference, so the schema drops the whole map rather than failing @@ -2409,6 +2429,7 @@ export function loadConfig(): OcxConfig { warnInheritedFastWireConflicts(configPath, config); warnDegradedStreamMode(parsed, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2438,6 +2459,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(retryResult.data as OcxConfig); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2463,6 +2485,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(salvaged.parsed); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2597,6 +2620,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf const warnings = configPlaceholderWarnings(normalized); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); + warnings.push(...degradedListenerWarnings(rawParsed, normalized)); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..f499a624e4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,4 @@ +import { AuxiliaryListenerBindError } from "./ports"; import { markActivity } from "../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../router"; import { @@ -2525,7 +2526,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const server = createServer(); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..d35eee8aed 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,5 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..120160f5b4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..410efd42e0 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..41308d716b 100644 --- a/structure/config.md +++ b/structure/config.md @@ -55,6 +55,8 @@ matters for maintainers is which groups exist and who resolves them: Env values are resolved through `src/config.ts`, so a config value naming an env var never persists the secret itself. +Malformed optional data-loopback and nested hub-management listener blocks are disabled in memory and reported by load-time warnings and read-only config diagnostics. The warning names only the field; unrelated providers and keys survive. Explicit writes remain strictly validated. + ## Config injection `src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..39253e4146 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,5 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..9ef1e9e823 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,5 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..64c04782f9 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..d5b43fade6 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..a80f9c613f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,5 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +Auxiliary listener startup failures report their own effective address and do not trigger public-port retries; the synchronous rollback contract is described in [Runtime](../runtime.md#lifecycle). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..864e1b4cdb 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9320132a04..b2e9e3b04d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -389,3 +389,5 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..c9a7c547d2 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..c830511887 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -71,6 +71,8 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. + A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound sockets before lifecycle release. The existing launchd/systemd installer remains the service owner and continues loading the data token from `service-api-token`; hub mode adds no service-manager diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..4bd96713eb 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,5 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..905e73ccf1 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..f82070117e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..c1597db686 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,5 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/tests/cli/cli-start-auxiliary-bind.test.ts b/tests/cli/cli-start-auxiliary-bind.test.ts new file mode 100644 index 0000000000..99b8e62236 --- /dev/null +++ b/tests/cli/cli-start-auxiliary-bind.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAvailablePort } from "../../src/server/ports"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const DEADLINE = watchdogMs(20_000); + +for (const listener of ["unauthenticatedLoopbackListener", "hub.managementIngress"] as const) { + for (const pinned of [false, true]) { + test(`${listener} failure never retries the public port (${pinned ? "pinned" : "soft"})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-auxiliary-bind-")); + const home = join(root, "home"); + const ocxHome = join(root, "ocx"); + const codexHome = join(root, "codex"); + for (const path of [home, ocxHome, codexHome]) mkdirSync(path); + const occupied = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied") }); + const auxiliaryPort = occupied.port!; + const publicPort = await findAvailablePort(0, "127.0.0.1"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: publicPort, hostname: "127.0.0.1", providers: {}, defaultProvider: "openai", + codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + claudeCode: { systemEnv: false }, + ...(listener === "hub.managementIngress" + ? { runtimeRole: "hub", hub: { managementIngress: { enabled: true, port: auxiliaryPort } } } + : { unauthenticatedLoopbackListener: { enabled: true, port: auxiliaryPort } }), + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", ...(pinned ? ["--port", String(publicPort)] : [])], { + cwd: root, + env: { HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome, + PATH: process.env.PATH ?? "", NO_PROXY: "127.0.0.1,localhost" }, + stdout: "pipe", stderr: "pipe", + }); + const deadline = setTimeout(() => child.kill(), DEADLINE); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + const output = stdout + stderr; + expect(code).not.toBe(0); + expect(output).toContain(`${listener} at 127.0.0.1:${auxiliaryPort}`); + expect(output).not.toContain("picking another"); + expect(output).not.toContain("waiting to retry the same port"); + const rebound = Bun.serve({ port: publicPort, hostname: "127.0.0.1", fetch: () => new Response("free") }); + await rebound.stop(true); + } finally { + clearTimeout(deadline); + if (child.exitCode === null) { child.kill(); await child.exited; } + await occupied.stop(true); + removeTreeWithRetry(root); + } + }, DEADLINE + 10_000); + } +} diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..df0e333c46 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, getDefaultConfig, loadConfig, + readConfigDiagnostics, saveConfig, validateConfigCandidate, } from "../../src/config"; @@ -139,3 +140,47 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf expect(loaded.providers.xai.note).toBe("keep me"); } }); + + +test.each([ + { enabled: "true" }, + { enabled: true, port: 70000 }, + "secret-shaped-malformed-listener-value", +])("malformed optional listeners warn without discarding unrelated settings: %j", listener => { + const config = { ...candidate(undefined), + apiKeys: [{ id: "preserved", name: "preserved", key: "fixture-key", createdAt: "2026-01-01" }], + unauthenticatedLoopbackListener: listener, + hub: { managementIngress: listener }, + }; + const bytes = JSON.stringify(config); + writeFileSync(getConfigPath(), bytes); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.providers.xai.note).toBe("keep me"); + expect(loaded.apiKeys?.[0]?.id).toBe("preserved"); + expect(loaded.unauthenticatedLoopbackListener).toBeUndefined(); + expect(loaded.hub?.managementIngress).toBeUndefined(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).toContain("unauthenticatedLoopbackListener ignored"); + expect(messages).toContain("hub.managementIngress ignored"); + expect(messages).not.toContain("secret-shaped-malformed-listener-value"); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); } +}); + +test.each([undefined, { enabled: false }])("absent or disabled listeners do not produce degradation warnings: %j", listener => { + writeFileSync(getConfigPath(), JSON.stringify({ ...candidate(undefined), + unauthenticatedLoopbackListener: listener, hub: { managementIngress: listener }, + })); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + loadConfig(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).not.toContain("Listener ignored"); + expect(messages).not.toContain("managementIngress ignored"); + } finally { warn.mockRestore(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b92e85757c..95b30cde0d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -192,6 +192,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 3b616d822e..3664b5f439 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -17,6 +17,7 @@ import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { runListenerShutdown } from "../../src/server/lifecycle"; import { + AuxiliaryListenerBindError, findAvailablePort, PortUnavailableError, setEphemeralPortAllocatorForTests, @@ -182,17 +183,21 @@ describe("hub management ingress", () => { }); test("a failed management bind rolls back both earlier listeners", async () => { - const managementPort = await freePort(); - const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); const squatter = Bun.serve({ - port: managementPort, + port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied"), }); + const managementPort = squatter.port!; + const loopbackPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); saveConfig(hubIngressConfig(managementPort, loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "hub.managementIngress", port: managementPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); for (const port of [publicPort, loopbackPort]) { const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); await rebound.stop(true); @@ -759,7 +764,11 @@ describe("unauthenticated loopback listener", () => { }); saveConfig(baseConfig(loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "unauthenticatedLoopbackListener", port: loopbackPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); const rebound = Bun.serve({ port: publicPort, diff --git a/tests/server/ports.test.ts b/tests/server/ports.test.ts index 6471244c56..f0acfa250f 100644 --- a/tests/server/ports.test.ts +++ b/tests/server/ports.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; import { pathToFileURL } from "node:url"; -import { findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; import { repoPath, repoRoot } from "../helpers/repo-root"; // Prototype overrides exist only inside the disposable child process. @@ -233,3 +233,14 @@ describe("port selection", () => { expect(await isPortAvailable(54321, "192.0.2.1")).toBe(false); }); }); + + +test("auxiliary bind diagnostics preserve non-conflict causes without claiming a busy port", () => { + const cause = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const failure = new AuxiliaryListenerBindError("hub.managementIngress", 12345, "127.0.0.1", cause); + expect(failure.cause).toBe(cause); + expect(failure.message).toContain("hub.managementIngress at 127.0.0.1:12345"); + expect(failure.message).not.toContain("busy"); + expect(isAddrInUse(failure)).toBe(false); + expect(isAddrInUse({ code: "EADDRINUSE" })).toBe(true); +}); From c597591b709e9b7eebe23888da8b1051193bf549 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:50:13 +0900 Subject: [PATCH 02/68] fix(start): retain degraded-listener evidence in salvage diagnostics --- src/config.ts | 5 ++++- tests/cli/cli-start-auxiliary-bind.test.ts | 4 +++- tests/config/config-load-degrade.test.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8209ff794f..23c3e80c69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3074,10 +3074,13 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // that ignores the error and writes it back preserves what the operator configured. const salvaged = salvageConfigCandidate(merged, retryResult.error); if (salvaged) { + const config = normalizeApiKeyIds(salvaged.parsed); + const warnings = degradedListenerWarnings(parsed, config); return { - config: normalizeApiKeyIds(salvaged.parsed), + config, source: "fallback", error: schemaDiagnosticsError(result.error), + ...(warnings.length > 0 ? { warnings } : {}), }; } diff --git a/tests/cli/cli-start-auxiliary-bind.test.ts b/tests/cli/cli-start-auxiliary-bind.test.ts index 99b8e62236..7f37113014 100644 --- a/tests/cli/cli-start-auxiliary-bind.test.ts +++ b/tests/cli/cli-start-auxiliary-bind.test.ts @@ -35,12 +35,14 @@ for (const listener of ["unauthenticatedLoopbackListener", "hub.managementIngres PATH: process.env.PATH ?? "", NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", }); - const deadline = setTimeout(() => child.kill(), DEADLINE); + let timedOut = false; + const deadline = setTimeout(() => { timedOut = true; child.kill(); }, DEADLINE); try { const [code, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); const output = stdout + stderr; + expect(timedOut, "CLI must exit on its own before the watchdog").toBe(false); expect(code).not.toBe(0); expect(output).toContain(`${listener} at 127.0.0.1:${auxiliaryPort}`); expect(output).not.toContain("picking another"); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index df0e333c46..b05a270b20 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -184,3 +184,19 @@ test.each([undefined, { enabled: false }])("absent or disabled listeners do not expect(messages).not.toContain("managementIngress ignored"); } finally { warn.mockRestore(); } }); + +test("salvaged diagnostics retain listener warnings alongside the routing error", () => { + const bytes = JSON.stringify({ ...candidate(undefined), + routingProfiles: { bad: { candidates: [{ provider: "xai", model: "model" }] } }, + unauthenticatedLoopbackListener: { enabled: "true" }, + hub: { managementIngress: { enabled: true, port: 70000 } }, + }); + writeFileSync(getConfigPath(), bytes); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("routingProfiles"); + expect(diagnostics.config.providers.xai.note).toBe("keep me"); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); +}); From d95743b5eaec4e9ba8f8ae886b5b829144662ada Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:51:23 +0900 Subject: [PATCH 03/68] test(config): activate routing-profile salvage in listener regression --- tests/config/config-load-degrade.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index b05a270b20..7e90c4177a 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -187,7 +187,7 @@ test.each([undefined, { enabled: false }])("absent or disabled listeners do not test("salvaged diagnostics retain listener warnings alongside the routing error", () => { const bytes = JSON.stringify({ ...candidate(undefined), - routingProfiles: { bad: { candidates: [{ provider: "xai", model: "model" }] } }, + routingProfiles: { bad: { candidates: [] } }, unauthenticatedLoopbackListener: { enabled: "true" }, hub: { managementIngress: { enabled: true, port: 70000 } }, }); From dcd2d0740301785ec624168073c7bfc59c10bb9f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:53:36 +0900 Subject: [PATCH 04/68] fix(search): retry clean empty answers without masking truncation Co-authored-by: Cortes Ventures --- .../content/docs/reference/proxy-formats.md | 7 ++ src/web-search/loop.ts | 58 ++++++++- structure/runtime.md | 4 + tests/web-search/web-search.test.ts | 116 ++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..1bd63cac79 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,13 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Empty search answers + +After hosted search, a clean but empty forced-answer pass receives one additional answer +attempt with tools removed and existing results retained. This can incur another model +request. A second empty answer fails; malformed calls and provider refusal or truncation +outcomes are preserved without this retry. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 99b275ed8d..8feceb4c27 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; +import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; @@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage { }; } +/** + * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty. + * The recovery also removes every tool, so the model has nothing to call and can only return text; + * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge} + * it is iteration-local and never touches the persisted `messages`. + */ +function forcedAnswerRetryNudge(): OcxMessage { + return { + role: "developer", + content: + "Your previous response contained no usable answer. Web search has finished for this turn and " + + "no tools are available for this response. Answer the user's question now in assistant text, " + + "using the web search results already gathered above. If those results are insufficient, say " + + "what is missing instead of returning an empty response.", + timestamp: Date.now(), + }; +} + function jsonError(status: number, message: string): Response { return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { status, @@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 + let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0 ? [...messages, forcedAnswerNudge()] : messages; + // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the + // model nothing to call, and the extra developer turn asks it for the text it just failed to + // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry + // cannot repeat the same empty or tool-shaped response. + const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0; + if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()]; const iterParsed: OcxParsedRequest = { ...parsed, stream: true, - context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools }, + ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}), + context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools }, }; // One cumulative header deadline spans every pool-key 429 rotation in this model iteration. // clear() stops only its timer after final headers; the direct turn signal remains attached to @@ -847,9 +875,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done"); + if (terminalEvent?.type === "done" && isTruncatedStopReason(terminalEvent.stopReason)) { + // A provider refusal or truncation is authoritative, even without text. + // Preserve it once; neither an empty-answer retry nor a generic 502 applies. + yield* replay(split.passthrough.slice(split.streamedPassthroughCount)); + return; + } if (terminalEvent?.type === "done" && (split.hasMalformedToolCall || (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) { + // #1001 fixed the silent success by failing here. A malformed call still fails: it + // reports a protocol problem, and replaying it would only re-ask an unwell upstream. + // Silence is different — it is recoverable, so retry exactly once with the results + // already gathered before failing the turn. + console.warn("[web-search-loop] unusable forced answer", JSON.stringify({ + model: parsed.modelId, + recoveryAttempt: emptyAnswerRetries, + searchCalls: split.calls.length, + malformed: split.hasMalformedToolCall, + stopReason: terminalEvent.stopReason, + eventTypes: [...new Set(split.passthrough.map(event => event.type))], + })); + if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) { + emptyAnswerRetries++; + console.warn("[web-search-loop] empty forced answer — retrying once without tools"); + yield { type: "heartbeat" }; + continue; + } throw new LoopError(502, "forced-answer pass produced no usable assistant output"); } } diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..ea8e98e277 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +### Empty forced search answers + +`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail, and recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index e182308a0d..5c41ad5e82 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -133,6 +133,122 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(frames.some(frame => frame.event === "response.completed")).toBe(true); expect(frames.some(frame => frame.event === "response.failed")).toBe(false); }); + + // #1001 chose to fail rather than complete silently, which turned silence into a dead turn: + // the user sees "stream disconnected before completion: forced-answer pass produced no usable + // assistant output". Silence is recoverable, so the pass is retried once with no tools before + // the same error is reported. Malformed calls still fail immediately. + describe("empty forced answer recovery", () => { + function sequenceAdapter(passes: AdapterEvent[][], seen: OcxParsedRequest[]): ProviderAdapter { + let pass = 0; + return { + name: "sequence", + buildRequest: (request) => { + seen.push(request); + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + for (const event of passes[Math.min(pass++, passes.length - 1)] ?? []) yield event; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + } + + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), + adapter: sequenceAdapter(passes, seen), + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + return collectSse(response.body!); + } + + test("an empty forced pass is retried once and completes", async () => { + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ]); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); + expect(frames.some(frame => frame.event === "response.failed")).toBe(false); + }); + + test("the recovery pass asks for text with every tool removed", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + // The search pass plus the empty forced pass plus exactly one recovery — no extra upstream call. + expect(seen).toHaveLength(3); + const recovery = seen[2]!; + expect(recovery.options.toolChoice).toBe("none"); + expect(recovery.context.tools).toEqual([]); + // The results gathered by the search reach the recovery turn as a tool result ... + expect(recovery.context.messages.filter(message => message.role === "toolResult")).toHaveLength(1); + // ... and the recovery turn carries the developer nudge that asks for the missing text. + expect(recovery.context.messages.some(message => + message.role === "developer" && String(message.content).includes("no tools are available"))) + .toBe(true); + }); + + test("recovery removes ordinary tools as well as web search", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([webSearchFirstPass, [{ type: "done" }], [{ type: "text_delta", text: "answer" }, { type: "done" }]], seen, true); + expect(seen).toHaveLength(3); + expect(seen[1]!.context.tools.length).toBeGreaterThan(0); + expect(seen[2]!.context.tools).toEqual([]); + expect(seen[2]!.options.toolChoice).toBe("none"); + }); + + for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) { + for (const partial of [false, true]) { + test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => { + const seen: OcxParsedRequest[] = []; + const terminalPass: AdapterEvent[] = [ + ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), + { type: "done", stopReason }, + ]; + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + expect(seen).toHaveLength(2); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); + expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); + }); + } + } + + test("a persistent empty forced pass still fails after the one recovery", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "done" }], + ], seen); + expect(seen).toHaveLength(3); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + }); + + test("a malformed forced call is not retried", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "tool_call_start", id: "", name: "" }, { type: "tool_call_end" }, { type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + expect(seen).toHaveLength(2); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + }); + }); }); const routedProvider: OcxProviderConfig = { From 45f703f356b20172ffd9a7301a9ecaf967deccdf Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:54:48 +0900 Subject: [PATCH 05/68] test(search): narrow terminal fixture projection --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 5c41ad5e82..61abb904fd 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -219,8 +219,9 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ]; const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); expect(seen).toHaveLength(2); - expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); - expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); + const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; + expect(terminalResponse.incomplete_details.reason).toBe(reason); if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); }); } From 3652da790b58177f5ae7eebecb9c8ba3527e9109 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:23 +0900 Subject: [PATCH 06/68] test(search): exercise live output truncation without duplicate replay --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 61abb904fd..9bf7c02444 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -157,7 +157,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = }; } - async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false, liveOutput = false) { const response = await runWithWebSearch({ parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), adapter: sequenceAdapter(passes, seen), @@ -166,6 +166,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, + streamRoutedModelOutput: liveOutput, }); return collectSse(response.body!); } @@ -217,7 +218,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), { type: "done", stopReason }, ]; - const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen, false, true); expect(seen).toHaveLength(2); expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; From 3bf0ae126a3d1bbb7d182d72dcaacb39614fc5b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:59:31 +0900 Subject: [PATCH 07/68] feat(remote): carry bounded executor and hub runtime adapters Carry #3458 runtime foundations with explicit session grants, private state stores and fail-closed Windows command support. Keep server and dashboard activation for the dependent integration layer. Co-authored-by: Ingwannu --- .gitignore | 3 + .npmignore | 1 + .../020_executor_runtime.md | 22 + native/remote-workspace-helper/Cargo.lock | 130 +++ native/remote-workspace-helper/Cargo.toml | 24 + native/remote-workspace-helper/src/main.rs | 49 ++ .../remote-workspace-helper/src/protocol.rs | 246 ++++++ .../src/sandbox/macos.rs | 19 + .../src/sandbox/mod.rs | 77 ++ .../src/sandbox/windows.rs | 15 + .../tests/live_confinement.rs | 75 ++ package.json | 5 + scripts/test-layout/layout.json | 16 + src/cli/remote-workspace.ts | 154 ++++ src/lib/windows-atomic-replace.ts | 1 + src/remote-control/index.ts | 233 +++++- .../workspace-agent-connection.ts | 366 +++++++++ .../workspace-claude-runtime.ts | 243 ++++++ src/remote-control/workspace-codex-runtime.ts | 531 +++++++++++++ src/remote-control/workspace-codex-sandbox.ts | 115 +++ .../workspace-command-runner.ts | 749 ++++++++++++++++++ src/remote-control/workspace-coordinator.ts | 230 ++++++ src/remote-control/workspace-device.ts | 585 ++++++++++++++ src/remote-control/workspace-executable.ts | 43 + src/remote-control/workspace-executor.ts | 396 +++++++++ src/remote-control/workspace-hub.ts | 519 ++++++++++++ src/remote-control/workspace-pi-runtime.ts | 382 +++++++++ src/remote-control/workspace-process.ts | 129 +++ src/remote-control/workspace-rpc.ts | 304 +++++++ src/remote-control/workspace-runtime.ts | 60 ++ src/remote-control/workspace-secret-store.ts | 39 + src/remote-control/workspace-sessions.ts | 730 +++++++++++++++++ src/remote-control/workspace-tool-bridge.ts | 192 +++++ structure/clients/claude-desktop.md | 2 + structure/clients/integrations.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + structure/remote-workspace.md | 18 +- structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + .../remote-workspace-agent-wire.test.ts | 324 ++++++++ ...e-workspace-app-server.integration.test.ts | 426 ++++++++++ ...emote-workspace-claude.integration.test.ts | 166 ++++ .../remote-workspace-cli-runtimes.test.ts | 67 ++ tests/clients/remote-workspace-cli.test.ts | 105 +++ .../remote-workspace-codex-runtime.test.ts | 120 +++ .../remote-workspace-command-runner.test.ts | 328 ++++++++ tests/clients/remote-workspace-device.test.ts | 158 ++++ tests/clients/remote-workspace-hub.test.ts | 211 +++++ ...remote-workspace-linux-confinement.test.ts | 114 +++ .../clients/remote-workspace-platform.test.ts | 182 +++++ .../remote-workspace-secret-store.test.ts | 105 +++ .../remote-workspace-session-binding.test.ts | 75 ++ .../clients/remote-workspace-sessions.test.ts | 352 ++++++++ .../remote-workspace-tool-bridge.test.ts | 87 ++ tests/clients/remote-workspace.test.ts | 464 +++++++++++ tests/fake-codex-server.ts | 4 + tests/fixtures/fake-claude-stream.ts | 8 + tests/fixtures/test-layout-expected.json | 16 + 62 files changed, 9984 insertions(+), 47 deletions(-) create mode 100644 native/remote-workspace-helper/Cargo.lock create mode 100644 native/remote-workspace-helper/Cargo.toml create mode 100644 native/remote-workspace-helper/src/main.rs create mode 100644 native/remote-workspace-helper/src/protocol.rs create mode 100644 native/remote-workspace-helper/src/sandbox/macos.rs create mode 100644 native/remote-workspace-helper/src/sandbox/mod.rs create mode 100644 native/remote-workspace-helper/src/sandbox/windows.rs create mode 100644 native/remote-workspace-helper/tests/live_confinement.rs create mode 100644 src/cli/remote-workspace.ts create mode 100644 src/remote-control/workspace-agent-connection.ts create mode 100644 src/remote-control/workspace-claude-runtime.ts create mode 100644 src/remote-control/workspace-codex-runtime.ts create mode 100644 src/remote-control/workspace-codex-sandbox.ts create mode 100644 src/remote-control/workspace-command-runner.ts create mode 100644 src/remote-control/workspace-coordinator.ts create mode 100644 src/remote-control/workspace-device.ts create mode 100644 src/remote-control/workspace-executable.ts create mode 100644 src/remote-control/workspace-executor.ts create mode 100644 src/remote-control/workspace-hub.ts create mode 100644 src/remote-control/workspace-pi-runtime.ts create mode 100644 src/remote-control/workspace-process.ts create mode 100644 src/remote-control/workspace-rpc.ts create mode 100644 src/remote-control/workspace-runtime.ts create mode 100644 src/remote-control/workspace-secret-store.ts create mode 100644 src/remote-control/workspace-sessions.ts create mode 100644 src/remote-control/workspace-tool-bridge.ts create mode 100644 tests/clients/remote-workspace-agent-wire.test.ts create mode 100644 tests/clients/remote-workspace-app-server.integration.test.ts create mode 100644 tests/clients/remote-workspace-claude.integration.test.ts create mode 100644 tests/clients/remote-workspace-cli-runtimes.test.ts create mode 100644 tests/clients/remote-workspace-cli.test.ts create mode 100644 tests/clients/remote-workspace-codex-runtime.test.ts create mode 100644 tests/clients/remote-workspace-command-runner.test.ts create mode 100644 tests/clients/remote-workspace-device.test.ts create mode 100644 tests/clients/remote-workspace-hub.test.ts create mode 100644 tests/clients/remote-workspace-linux-confinement.test.ts create mode 100644 tests/clients/remote-workspace-platform.test.ts create mode 100644 tests/clients/remote-workspace-secret-store.test.ts create mode 100644 tests/clients/remote-workspace-session-binding.test.ts create mode 100644 tests/clients/remote-workspace-sessions.test.ts create mode 100644 tests/clients/remote-workspace-tool-bridge.test.ts create mode 100644 tests/clients/remote-workspace.test.ts create mode 100644 tests/fixtures/fake-claude-stream.ts diff --git a/.gitignore b/.gitignore index ce10233dcc..f32218aafd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ tests/**/.tmp-* # `git add` three separate times and reached `dev` once — see # tests/ci-workflows/repo-hygiene.test.ts, which fails if any path here becomes tracked again. go/ + +# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. +native/**/target/ diff --git a/.npmignore b/.npmignore index acf3a0c4d0..cfbe1d3750 100644 --- a/.npmignore +++ b/.npmignore @@ -19,6 +19,7 @@ gui/eslint.config.* gui/bun.lock # misc +native/remote-workspace-helper/target/ *.test.ts *.map .DS_Store diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md index 550b2a5bb5..e20200c7c3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md +++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md @@ -82,3 +82,25 @@ REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connect REMOTE-ARCH-006: Use existing required private-file/Windows ACL primitives for new identity and bearer stores. Check permission setup failures and refuse loading/saving secrets when enforcement fails. Do not change global config-store behavior. Record exact selected existing helper in phase-2 P after reading the owner; no best-effort function is accepted as proof. REMOTE-ARCH-007: Codex real App Server tests depend on OCX_CODEX_BIN; Claude real integration on OCX_CLAUDE_BIN; Pi on OCX_PI_BIN. The Linux confinement case can return without execution unless OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 or bwrap is available. Current generic CI alone does not prove those paths. Mock tests prove lifecycle and tool-routing contracts only; native Hub isolation and executor confinement stay explicit final acceptance gaps when not activated. For each adapter separately record denied local tools, inherited plugins/hooks/config, offline refusal and teardown; inspect source plus hosted mocks, no claims of live CLI confinement from flags alone. + +## Phase-2 revalidation and exact owner choices + +Previous D: wp1 inactive foundation source cycle complete at 726ddc7fc0; final hosted proof remains wp4. Continue in child branch codex/260912-60plus-remote-runtime. Existing public exports and added host-negative coverage are retained. + +REMOTE-ARCH-004: storage modules import atomicWriteFile directly from src/config/atomic-write.ts and getConfigDir from src/config/paths.ts, avoiding the broad config.ts barrel. Device CLI orchestration retains explicit runner construction because it computes actual availability after root approval; no import-time probe exists. This is intentional sequential coupling. Server seams in phase 3 use narrow structural connection/session interfaces rather than pulling concrete remote classes into shared request types. No remote module imports server surfaces. + +REMOTE-ARCH-006 exact helpers: NEW src/remote-control/workspace-secret-store.ts owns prepareWorkspaceSecretDirectory(directory) and hardenWorkspaceSecretFile(path). On POSIX use chmodSync with propagated failure and lstat directory/file identity/type checks. On Windows call existing src/lib/windows-secret-acl.ts hardenSecretDir/hardenSecretPath with required:true. Reject symlink state targets. All three stores use this before reads and before atomicWriteFile. Existing atomic-write.ts already creates an empty private descriptor, hardens before writing bytes, and scrubs failures; retain it. Tests: NEW tests/clients/remote-workspace-secret-store.test.ts covers owner-only POSIX file mode, unexpected path types/symlinks and failed reads; hosted Windows ACL owner tests remain applicable. No global config behavior changes. + +src/lib/windows-atomic-replace.ts change is the new ReplacePublisher literal remote-workspace (the function is already exported). Use existing counter serialization/consumers unchanged: creation at executor write, diagnostic key serialization, dynamic record readers; no closed switch to extend. + +NEW tests/clients/remote-workspace-session-binding.test.ts covers session/device/root/capability mismatches with zero execution and a valid positive control, using encrypted messages and independent fixtures. MODIFY agent-wire, hub, sessions and device tests to assert subset negotiation and presence intersection. Platform runner source retains existing fail-closed native paths; remove stale comment claiming supported macOS commands. + +### Audit amendment: store-level failure propagation + +Hub/Device/Session file-store constructors accept an optional narrow permissions dependency containing prepareDirectory and hardenFile, defaulting to the required production helper. Load returns null for absent files; existing files require directory and file checks before secret reads. Save prepares directory, hardens an existing target, then invokes the existing private atomic writer. For each store, injected directory/file hardening throws must propagate, preserve existing bytes and prevent secret IO. New-state first-run controls return null then save/load valid fixtures. Add all three store cases to remote-workspace-secret-store.test.ts; this injection observes caller ordering rather than relying on ACL-owner tests alone. + +### Native containment amendment + +Independent source review requires a protected Linux bubblewrap executable outside writable roots, with identity revalidation before use. Custom executable files and their parent chain must not be writable by group/other; canonical system symlinks are resolved before checking. Workspace roots cannot contain the executable; every invocation rechecks. Add source/runner regression fixtures without claiming a local run. + +Windows command availability remains disabled in this carry: nativeRemoteWorkspaceCommandRunnerAvailable returns false before invoking the helper, and the official Windows helper rejects public probe/run without allocating OS resources. The candidate Windows implementation remains in original PR history; do not retain callable unverified entrypoints. This matches the fail-closed macOS policy and preserves independently authorized file tools. Update native denial tests and docs; Windows working-command acceptance stays OPEN. A future lifecycle owner and hosted cancellation/cleanup evidence are required before re-enablement. This is a safety limitation, not completion of Windows commands. diff --git a/native/remote-workspace-helper/Cargo.lock b/native/remote-workspace-helper/Cargo.lock new file mode 100644 index 0000000000..8dba097e9d --- /dev/null +++ b/native/remote-workspace-helper/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/remote-workspace-helper/Cargo.toml b/native/remote-workspace-helper/Cargo.toml new file mode 100644 index 0000000000..65bd1d0ba7 --- /dev/null +++ b/native/remote-workspace-helper/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", +] } diff --git a/native/remote-workspace-helper/src/main.rs b/native/remote-workspace-helper/src/main.rs new file mode 100644 index 0000000000..8312186b8d --- /dev/null +++ b/native/remote-workspace-helper/src/main.rs @@ -0,0 +1,49 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod protocol; +mod sandbox; + +use std::io::{self, Read, Write}; + +use protocol::{HelperRequest, HelperResponse, MAX_REQUEST_BYTES, PROTOCOL_VERSION}; + +fn main() { + if std::env::args().nth(1).as_deref() == Some("__probe-child") { + std::process::exit(sandbox::run_probe_child()); + } + + let response = match read_request().and_then(handle_request) { + Ok(response) => response, + Err(error) => HelperResponse::error(error), + }; + let mut stdout = io::stdout().lock(); + if serde_json::to_writer(&mut stdout, &response).is_err() || stdout.write_all(b"\n").is_err() { + std::process::exit(2); + } +} + +fn read_request() -> Result { + let mut body = Vec::new(); + io::stdin() + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| "could not read helper request".to_owned())?; + if body.len() > MAX_REQUEST_BYTES { + return Err("helper request exceeds its size limit".to_owned()); + } + let request: HelperRequest = + serde_json::from_slice(&body).map_err(|_| "helper request is invalid".to_owned())?; + request.validate()?; + Ok(request) +} + +fn handle_request(request: HelperRequest) -> Result { + if request.version != PROTOCOL_VERSION { + return Err("unsupported helper protocol version".to_owned()); + } + match request.operation.as_str() { + "probe" => sandbox::probe().map(|()| HelperResponse::probe_success()), + "run" => sandbox::run(&request).map(HelperResponse::command_success), + _ => Err("unsupported helper operation".to_owned()), + } +} diff --git a/native/remote-workspace-helper/src/protocol.rs b/native/remote-workspace-helper/src/protocol.rs new file mode 100644 index 0000000000..900f630e5c --- /dev/null +++ b/native/remote-workspace-helper/src/protocol.rs @@ -0,0 +1,246 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_REQUEST_BYTES: usize = 64 * 1024; +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMMAND_ARGUMENTS: usize = 64; +const MAX_COMMAND_ARGUMENT_BYTES: usize = 4096; +const MAX_COMMAND_BYTES: usize = 16 * 1024; +const MAX_TOOLCHAIN_ROOTS: usize = 16; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HelperRequest { + pub version: u8, + pub operation: String, + #[serde(default)] + pub root: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub command: Vec, + #[serde(default)] + pub toolchain_roots: Vec, + #[serde(default)] + pub timeout_ms: u64, + #[serde(default)] + pub max_output_bytes: usize, + #[serde(default)] + pub network_access: bool, +} + +impl HelperRequest { + pub fn validate(&self) -> Result<(), String> { + if self.operation == "probe" { + if !self.root.is_empty() + || !self.cwd.is_empty() + || !self.command.is_empty() + || !self.toolchain_roots.is_empty() + || self.timeout_ms != 0 + || self.max_output_bytes != 0 + || self.network_access + { + return Err("probe request must not carry command authority".to_owned()); + } + return Ok(()); + } + if self.operation != "run" { + return Ok(()); + } + validate_path(&self.root, "workspace root")?; + validate_path(&self.cwd, "command cwd")?; + if !Path::new(&self.root).is_absolute() || !Path::new(&self.cwd).is_absolute() { + return Err("workspace root and cwd must be absolute".to_owned()); + } + if self.command.is_empty() || self.command.len() > MAX_COMMAND_ARGUMENTS { + return Err("invalid command vector".to_owned()); + } + let mut command_bytes = 0usize; + for value in &self.command { + if value.is_empty() || value.len() > MAX_COMMAND_ARGUMENT_BYTES || value.contains('\0') + { + return Err("invalid command vector".to_owned()); + } + command_bytes = command_bytes + .checked_add(value.len()) + .ok_or_else(|| "command vector is too large".to_owned())?; + } + if command_bytes > MAX_COMMAND_BYTES { + return Err("command vector is too large".to_owned()); + } + if self.toolchain_roots.len() > MAX_TOOLCHAIN_ROOTS { + return Err("too many toolchain roots".to_owned()); + } + for path in &self.toolchain_roots { + validate_path(path, "toolchain root")?; + if !Path::new(path).is_absolute() { + return Err("toolchain roots must be absolute".to_owned()); + } + } + if !(1..=60_000).contains(&self.timeout_ms) { + return Err("command timeout is outside its limit".to_owned()); + } + if !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_output_bytes) { + return Err("command output limit is outside its limit".to_owned()); + } + Ok(()) + } + + #[cfg(target_os = "windows")] + pub fn canonical_paths(&self) -> Result { + let root = canonical_directory(&self.root, "workspace root")?; + let cwd = canonical_directory(&self.cwd, "command cwd")?; + if !cwd.starts_with(&root) { + return Err("command cwd escaped its workspace root".to_owned()); + } + let mut toolchain_roots = Vec::with_capacity(self.toolchain_roots.len()); + for value in &self.toolchain_roots { + let canonical = canonical_directory(value, "toolchain root")?; + if !toolchain_roots.contains(&canonical) { + toolchain_roots.push(canonical); + } + } + Ok(CanonicalPaths { + root, + cwd, + toolchain_roots, + }) + } +} + +fn validate_path(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > MAX_PATH_BYTES || value.contains('\0') { + return Err(format!("invalid {label}")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn canonical_directory(value: &str, label: &str) -> Result { + let original = Path::new(value); + let metadata = + std::fs::symlink_metadata(original).map_err(|_| format!("{label} is unavailable"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} must remain a real directory")); + } + original + .canonicalize() + .map_err(|_| format!("{label} is unavailable")) +} + +#[cfg(target_os = "windows")] +#[derive(Debug)] +pub struct CanonicalPaths { + pub root: PathBuf, + pub cwd: PathBuf, + pub toolchain_roots: Vec, +} + +#[derive(Debug)] +pub struct CommandOutcome { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperResponse { + version: u8, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + probe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stdout_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stderr_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl HelperResponse { + pub fn error(error: String) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: false, + probe: None, + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: Some(limit_error(error)), + } + } + + pub fn probe_success() -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: Some(true), + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: None, + } + } + + pub fn command_success(outcome: CommandOutcome) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: None, + exit_code: Some(outcome.exit_code), + stdout_base64: Some(STANDARD.encode(outcome.stdout)), + stderr_base64: Some(STANDARD.encode(outcome.stderr)), + error: None, + } + } +} + +fn limit_error(mut value: String) -> String { + const MAX_ERROR_CHARS: usize = 512; + if value.chars().count() <= MAX_ERROR_CHARS { + return value; + } + value = value.chars().take(MAX_ERROR_CHARS).collect(); + value.push('…'); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_authority_smuggled_into_probe() { + let request: HelperRequest = + serde_json::from_str(r#"{"version":1,"operation":"probe","command":["whoami"]}"#) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } + + #[test] + fn rejects_unknown_wire_fields() { + assert!( + serde_json::from_str::( + r#"{"version":1,"operation":"probe","surprise":true}"#, + ) + .is_err() + ); + } + + #[test] + fn bounds_command_shape_before_platform_code() { + let request: HelperRequest = serde_json::from_str( + r#"{"version":1,"operation":"run","root":"/tmp/a","cwd":"/tmp/a","command":["x"],"timeoutMs":0,"maxOutputBytes":262144}"#, + ) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } +} diff --git a/native/remote-workspace-helper/src/sandbox/macos.rs b/native/remote-workspace-helper/src/sandbox/macos.rs new file mode 100644 index 0000000000..2052f82707 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/macos.rs @@ -0,0 +1,19 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const MACOS_CONFINEMENT_UNAVAILABLE: &str = + "macOS Remote Workspace command confinement is unavailable; file tools remain enabled"; + +/// macOS has no unprivileged Job Object or cgroup equivalent that can revoke every descendant's +/// workspace access. A Seatbelt profile can constrain a process, but allowing subprocesses lets a +/// descendant call `setsid()` and outlive cancellation. Importing broad system profiles merely to +/// make a single-process probe start would also widen unrelated host-service authority. Until a +/// native containment owner closes both boundaries, command execution must stay unavailable. +pub fn probe() -> Result<(), String> { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +/// Keep the helper itself fail-closed even if a caller bypasses OCX capability negotiation and +/// submits a `run` request directly. +pub fn run(_request: &HelperRequest) -> Result { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/src/sandbox/mod.rs b/native/remote-workspace-helper/src/sandbox/mod.rs new file mode 100644 index 0000000000..4b9bf551d4 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/mod.rs @@ -0,0 +1,77 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use crate::protocol::{CommandOutcome, HelperRequest}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[cfg(target_os = "macos")] +pub use macos::{probe, run}; +#[cfg(target_os = "windows")] +pub use windows::{probe, run}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn probe() -> Result<(), String> { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn run(_request: &HelperRequest) -> Result { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +pub fn run_probe_child() -> i32 { + let mut args = std::env::args().skip(2); + let Some(workspace) = args.next() else { + return 20; + }; + let Some(outside_file) = args.next() else { + return 21; + }; + let Some(outside_write) = args.next() else { + return 22; + }; + let Some(listener_address) = args.next() else { + return 23; + }; + let Some(existing_workspace_file) = args.next() else { + return 24; + }; + if args.next().is_some() { + return 24; + } + + let marker = std::path::Path::new(&workspace).join("probe-marker"); + if fs::write(&marker, b"sandboxed").is_err() { + return 25; + } + if !matches!(fs::read(&existing_workspace_file), Ok(value) if value == b"existing") + || fs::write(&existing_workspace_file, b"updated").is_err() + { + return 29; + } + let mut outside = Vec::new(); + if OpenOptions::new() + .read(true) + .open(&outside_file) + .and_then(|mut file| file.read_to_end(&mut outside)) + .is_ok() + { + return 26; + } + if fs::write(&outside_write, b"escaped").is_ok() { + return 27; + } + let Ok(listener_address) = listener_address.parse::() else { + return 23; + }; + if TcpStream::connect_timeout(&listener_address, Duration::from_millis(500)).is_ok() { + return 28; + } + 0 +} diff --git a/native/remote-workspace-helper/src/sandbox/windows.rs b/native/remote-workspace-helper/src/sandbox/windows.rs new file mode 100644 index 0000000000..2ecefef055 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/windows.rs @@ -0,0 +1,15 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const WINDOWS_CONFINEMENT_UNAVAILABLE: &str = + "Windows Remote Workspace command confinement is unavailable; command execution is disabled"; + +// A command-capable implementation must retain cleanup ownership through helper cancellation +// and establish Job membership atomically. Until that owner is implemented and verified, +// direct helper requests and capability probes refuse before allocating OS resources. +pub fn probe() -> Result<(), String> { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +pub fn run(_request: &HelperRequest) -> Result { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/tests/live_confinement.rs b/native/remote-workspace-helper/tests/live_confinement.rs new file mode 100644 index 0000000000..e735029ac8 --- /dev/null +++ b/native/remote-workspace-helper/tests/live_confinement.rs @@ -0,0 +1,75 @@ +#![cfg(any(target_os = "macos", target_os = "windows"))] + +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_helper(request: &Value) -> Value { + let binary = env!("CARGO_BIN_EXE_opencodex-remote-workspace-helper"); + let mut child = Command::new(binary) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("native helper starts"); + child + .stdin + .take() + .expect("native helper stdin") + .write_all(&serde_json::to_vec(request).expect("helper request serializes")) + .expect("helper request is written"); + let output = child.wait_with_output().expect("native helper exits"); + assert!( + output.status.success(), + "helper stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("helper response is JSON") +} + +fn run_probe() -> Value { + run_helper(&serde_json::json!({ "version": 1, "operation": "probe" })) +} + +#[cfg(target_os = "windows")] +#[test] +fn native_helper_keeps_windows_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "Windows Remote Workspace command confinement is unavailable; command execution is disabled" + }); + assert_eq!(run_probe(), unavailable); + let root = std::env::current_dir().expect("test cwd"); + assert_eq!(run_helper(&serde_json::json!({ + "version": 1, "operation": "run", "root": root, "cwd": root, + "command": ["cmd.exe", "/c", "exit"], "timeoutMs": 1000, "maxOutputBytes": 4096 + })), unavailable); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_helper_keeps_macos_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "macOS Remote Workspace command confinement is unavailable; file tools remain enabled" + }); + assert_eq!(run_probe(), unavailable); + + let root = std::env::current_dir().expect("test cwd"); + assert_eq!( + run_helper(&serde_json::json!({ + "version": 1, + "operation": "run", + "root": root, + "cwd": root, + "command": ["/usr/bin/true"], + "toolchainRoots": [], + "timeoutMs": 5_000, + "maxOutputBytes": 16 * 1024, + "networkAccess": false + })), + unavailable + ); +} diff --git a/package.json b/package.json index 6fae3e4d49..593ae79698 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "README.md", "SPONSORS.md", "AGENTS_INSTALL.md", + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", "LICENSE" ], "engines": { @@ -52,6 +55,8 @@ "structure:check": "bun scripts/structure-ssot.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fa89d735b8..55b8a9bf44 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1051,6 +1051,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", diff --git a/src/cli/remote-workspace.ts b/src/cli/remote-workspace.ts new file mode 100644 index 0000000000..5eb8a59c44 --- /dev/null +++ b/src/cli/remote-workspace.ts @@ -0,0 +1,154 @@ +import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device"; +import { + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceAgent, + type PairRemoteWorkspaceDeviceOptions, + type RemoteWorkspaceAgentRunStatus, + type RemoteWorkspaceDeviceStateStore, +} from "../remote-control/workspace-device"; +import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner"; +import { + CliUsageError, + readSecretLine, + rejectArgs, + takeFlag, + takeJsonFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const REMOTE_WORKSPACE_USAGE = `Usage: + ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json] + ocx remote-workspace agent + ocx remote-workspace status [--json]`; + +export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps { + store?: RemoteWorkspaceDeviceStateStore; + pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise; + runAgent?: typeof runRemoteWorkspaceAgent; + signal?: AbortSignal; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; +} + +function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] { + const roots: string[] = []; + for (;;) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE); + roots.push(value); + args.splice(index, 2); + } + return roots; +} + +function publicStatus(state: RemoteWorkspaceDeviceState | null): Record { + if (!state) return { paired: false }; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner( + createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }), + state.capabilities, + ); + return { + paired: true, + hubUrl: state.hubUrl, + deviceId: state.deviceId, + deviceName: state.deviceName, + devicePlatform: state.devicePlatform, + capabilities, + roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })), + toolchainRoots: state.toolchainRoots, + }; +} + +export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise { + const args = [...rawArgs]; + const command = args.shift(); + const store = deps.store ?? new RemoteWorkspaceDeviceFileStore(); + if (command === "status") { + const wantsJson = takeJsonFlag(args); + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const status = publicStatus(store.load()); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else if (!status.paired) console.log("Remote Workspace executor is not paired."); + else { + console.log(`Remote Workspace executor: ${status.deviceName}`); + console.log(`Hub: ${status.hubUrl}`); + console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`); + console.log(`Workspace roots: ${(status.roots as unknown[]).length}`); + } + return 0; + } + if (command === "pair") { + const wantsJson = takeJsonFlag(args); + const readCode = takeFlag(args, "--pairing-code-stdin"); + const name = takeOption(args, "--name"); + const nativeHelperPath = takeOption(args, "--executor-helper"); + const roots = takeRepeatedPathFlag(args, "--root"); + const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root"); + const hubUrl = args.shift(); + if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError( + "pair requires , --pairing-code-stdin, and at least one --root", + REMOTE_WORKSPACE_USAGE, + ); + rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true }); + const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code"); + const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({ + hubUrl, + pairingCode, + ...(name ? { name } : {}), + roots: roots.map(path => ({ path })), + toolchainRoots, + ...(nativeHelperPath ? { nativeHelperPath } : {}), + store, + }); + const status = publicStatus(state); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else { + console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`); + console.log("Run `ocx remote-workspace agent` to keep this executor online."); + } + return 0; + } + if (command === "agent") { + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const state = store.load(); + if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE); + const controller = deps.signal ? null : new AbortController(); + const signal = deps.signal ?? controller!.signal; + const stop = () => controller?.abort(); + if (controller) { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + try { + await (deps.runAgent ?? runRemoteWorkspaceAgent)({ + state, + signal, + onStatus: deps.onStatus ?? (status => { + if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`); + if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`); + }), + }); + } finally { + if (controller) { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } + } + return 0; + } + throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE); +} diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 0f3ba94552..a876c98bca 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -33,6 +33,7 @@ export type ReplacePublisher = | "claude-agents" | "lab-automation" | "lab-ledger" + | "remote-workspace" | "storage-cleanup" | "tray"; diff --git a/src/remote-control/index.ts b/src/remote-control/index.ts index 256832a324..352ff68043 100644 --- a/src/remote-control/index.ts +++ b/src/remote-control/index.ts @@ -1,3 +1,25 @@ +export { + parseRemoteControlClientHello, + parseRemoteControlHostHello, + serializeRemoteControlHello, + generateRemoteControlIdentityKeyPair, + RemoteControlCipher, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +export type { + RemoteControlIdentityKeyPair, + CreateRemoteControlClientHandshakeOptions, + AcceptRemoteControlClientHelloOptions, +} from "./crypto"; +export { + RemoteControlHost, +} from "./host"; +export type { + RemoteControlTerminal, + RemoteControlTerminalFactory, + RemoteControlHostOptions, +} from "./host"; export { REMOTE_CONTROL_PROTOCOL_VERSION, REMOTE_CONTROL_RELAY_HEADER_BYTES, @@ -24,28 +46,6 @@ export type { RemoteControlRelayFrame, RemoteControlApplicationFrame, } from "./protocol"; -export { - parseRemoteControlClientHello, - parseRemoteControlHostHello, - serializeRemoteControlHello, - generateRemoteControlIdentityKeyPair, - RemoteControlCipher, - RemoteControlClientHandshake, - acceptRemoteControlClientHello, -} from "./crypto"; -export type { - RemoteControlIdentityKeyPair, - CreateRemoteControlClientHandshakeOptions, - AcceptRemoteControlClientHelloOptions, -} from "./crypto"; -export { - RemoteControlHost, -} from "./host"; -export type { - RemoteControlTerminal, - RemoteControlTerminalFactory, - RemoteControlHostOptions, -} from "./host"; export { OpaqueRemoteControlRelay, } from "./relay"; @@ -53,6 +53,176 @@ export type { RemoteControlRelayPeer, OpaqueRemoteControlRelayOptions, } from "./relay"; +export { + RemoteWorkspaceHubAgentConnection, + RemoteWorkspaceExecutorAgentConnection, +} from "./workspace-agent-connection"; +export type { + RemoteWorkspaceControlSocket, +} from "./workspace-agent-connection"; +export { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, + isRemoteWorkspaceAgentProfile, + serializeRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + parseRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export type { + RemoteWorkspaceAgentProfile, + RemoteWorkspaceHubMessage, + RemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export { + ClaudeRemoteWorkspaceRuntimeFactory, +} from "./workspace-claude-runtime"; +export type { + ClaudeRemoteWorkspaceRuntimeOptions, +} from "./workspace-claude-runtime"; +export { + CodexRemoteWorkspaceRuntimeFactory, +} from "./workspace-codex-runtime"; +export type { + CodexRemoteWorkspaceRuntimeOptions, +} from "./workspace-codex-runtime"; +export { + resolveCodexLinuxSandboxBinary, + codexRemotePermissionProfileCompatibility, +} from "./workspace-codex-sandbox"; +export { + pinRemoteWorkspaceNativeHelper, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + linuxRemoteWorkspaceCommandArgv, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + nativeRemoteWorkspaceCommandRunnerAvailable, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "./workspace-command-runner"; +export type { + LinuxRemoteWorkspaceCommandRunnerOptions, + RemoteWorkspaceNativeHelperDescriptor, + NativeRemoteWorkspaceCommandRunnerOptions, +} from "./workspace-command-runner"; +export { + remoteWorkspaceThreadStartParams, + RemoteWorkspaceCoordinator, +} from "./workspace-coordinator"; +export type { + RemoteWorkspaceSessionBinding, + RemoteWorkspaceTransport, + AppServerDynamicToolRequest, + AppServerDynamicToolResponse, +} from "./workspace-coordinator"; +export { + REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + normalizeRemoteWorkspaceHubUrl, + parseRemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + connectRemoteWorkspaceAgent, + runRemoteWorkspaceAgent, +} from "./workspace-device"; +export type { + RemoteWorkspaceDeviceRoot, + RemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceStateStore, + PairRemoteWorkspaceDeviceOptions, + RemoteWorkspaceWebSocketLike, + RemoteWorkspaceWebSocketFactory, + RemoteWorkspaceAgentHandle, + RemoteWorkspaceAgentRunStatus, +} from "./workspace-device"; +export { + findExecutableOnPath, +} from "./workspace-executable"; +export { + validateRemoteWorkspaceRelativePath, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +export type { + RemoteWorkspaceRoot, + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutorOptions, + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; +export { + REMOTE_WORKSPACE_HUB_STATE_VERSION, + REMOTE_WORKSPACE_MAX_DEVICES, + REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE, + RemoteWorkspacePairingRateLimitError, + parseRemoteWorkspaceHubState, + RemoteWorkspaceHubFileStore, + RemoteWorkspaceHub, +} from "./workspace-hub"; +export type { + RemoteWorkspaceRootAdvertisement, + RemoteWorkspaceStoredDevice, + RemoteWorkspaceHubState, + RemoteWorkspaceHubStateStore, + RemoteWorkspacePublicDevice, + RemoteWorkspacePairingGrant, + RemoteWorkspacePairDeviceInput, + RemoteWorkspacePairDeviceResult, +} from "./workspace-hub"; +export { + PiRemoteWorkspaceRuntimeFactory, +} from "./workspace-pi-runtime"; +export type { + PiRemoteWorkspaceRuntimeOptions, +} from "./workspace-pi-runtime"; +export { + remoteWorkspaceProcessInvocation, + waitForRemoteWorkspaceProcessExit, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + removeRemoteWorkspaceIsolation, +} from "./workspace-process"; +export type { + RemoteWorkspaceProcessInvocationOptions, + RemoteWorkspaceOwnedProcess, + StopRemoteWorkspaceProcessOptions, +} from "./workspace-process"; +export { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + frameRemoteWorkspaceRpcMessage, + RemoteWorkspaceRpcReassembler, +} from "./workspace-rpc-framing"; +export { + EncryptedRemoteWorkspaceTransport, + EncryptedRemoteWorkspaceExecutorEndpoint, +} from "./workspace-rpc"; +export type { + EncryptedRemoteWorkspaceTransportOptions, + EncryptedRemoteWorkspaceExecutorEndpointOptions, +} from "./workspace-rpc"; +export { + REMOTE_WORKSPACE_SESSION_STATE_VERSION, + parseRemoteWorkspaceSessionState, + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; +export type { + RemoteWorkspaceSessionStatus, + RemoteWorkspaceAccessMode, + RemoteWorkspaceSessionEvent, + RemoteWorkspaceSessionSummary, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceSessionState, + RemoteWorkspaceSessionStateStore, +} from "./workspace-sessions"; +export { + startRemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; +export type { + RemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; export { REMOTE_WORKSPACE_TOOL_NAMESPACE, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, @@ -76,25 +246,6 @@ export type { RemoteWorkspaceToolCallParams, RemoteWorkspaceToolResult, } from "./workspace-tools"; -export { - REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, - REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, - isRemoteWorkspaceAgentProfile, - serializeRemoteWorkspaceHubMessage, - serializeRemoteWorkspaceAgentMessage, - parseRemoteWorkspaceHubMessage, - parseRemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export type { - RemoteWorkspaceAgentProfile, - RemoteWorkspaceHubMessage, - RemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export { - REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, - frameRemoteWorkspaceRpcMessage, - RemoteWorkspaceRpcReassembler, -} from "./workspace-rpc-framing"; export { truncateRemoteWorkspaceUtf8, } from "./workspace-utf8"; diff --git a/src/remote-control/workspace-agent-connection.ts b/src/remote-control/workspace-agent-connection.ts new file mode 100644 index 0000000000..095fcad6ca --- /dev/null +++ b/src/remote-control/workspace-agent-connection.ts @@ -0,0 +1,366 @@ +import type { RemoteControlIdentityKeyPair } from "./crypto"; +import { + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +import type { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, +} from "./workspace-rpc"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceAgentProfile, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; + +const SESSION_OPEN_TIMEOUT_MS = 10_000; + +export interface RemoteWorkspaceControlSocket { + send(value: string): void | Promise; + close(code: number, reason: string): void; +} + +interface PendingHubSession { + handshake: RemoteControlClientHandshake; + resolve(transport: EncryptedRemoteWorkspaceTransport): void; + reject(error: Error): void; + timer: ReturnType; +} + +function safeReason(value: string): string { + const cleaned = value.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const selected = cleaned || "remote workspace session closed"; + return truncateRemoteWorkspaceUtf8(selected, 120); +} + +/** Hub-side representation of one authenticated, online OCX-only executor. */ +export class RemoteWorkspaceHubAgentConnection { + private readonly pending = new Map(); + private readonly active = new Map(); + private readonly cancelledSessionIds = new Set(); + private closed = false; + private presenceAccepted = false; + private presencePending = false; + private currentCapabilities: RemoteWorkspaceCapability[]; + + constructor(private readonly options: { + deviceId: string; + devicePublicKey: string; + hubIdentity: RemoteControlIdentityKeyPair; + socket: RemoteWorkspaceControlSocket; + capabilities?: readonly RemoteWorkspaceCapability[]; + onCapabilities?: (capabilities: readonly RemoteWorkspaceCapability[]) => void; + sessionOpenTimeoutMs?: number; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + isOnline(): boolean { + return !this.closed && this.presenceAccepted; + } + + capabilities(): RemoteWorkspaceCapability[] { + return [...this.currentCapabilities]; + } + + async openSession(options: { + sessionId: string; + rootId: string; + profile: RemoteWorkspaceAgentProfile; + capabilities: readonly RemoteWorkspaceCapability[]; + }): Promise { + if (!this.isOnline()) throw new Error("remote workspace executor is offline"); + if (this.pending.has(options.sessionId) || this.active.has(options.sessionId)) { + throw new Error("remote workspace session already exists"); + } + if (this.pending.size + this.active.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (!Array.isArray(options.capabilities)) throw new Error("remote workspace session requires explicit capabilities"); + const requestedCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + if (requestedCapabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace session requests an unavailable capability"); + } + const handshake = RemoteControlClientHandshake.create({ + sessionId: options.sessionId, + deviceId: this.options.deviceId, + commandProfile: options.profile, + capabilities: requestedCapabilities, + accountPrivateKey: this.options.hubIdentity.privateKey, + }); + const timeoutMs = this.options.sessionOpenTimeoutMs ?? SESSION_OPEN_TIMEOUT_MS; + const opened = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(options.sessionId); + this.rememberCancelledSession(options.sessionId); + reject(new Error("remote workspace session handshake timed out")); + }, timeoutMs); + this.pending.set(options.sessionId, { handshake, resolve, reject, timer }); + }); + try { + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: options.rootId, + clientHello: handshake.hello, + })); + } catch (error) { + const pending = this.pending.get(options.sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(options.sessionId); + pending.reject(error instanceof Error ? error : new Error("remote workspace session send failed")); + } + } + return await opened; + } + + receive(raw: string | Uint8Array): void { + if (this.closed) throw new Error("remote workspace executor is offline"); + const message = parseRemoteWorkspaceAgentMessage(raw); + if (message.type === "presence") { + if (this.presenceAccepted || this.presencePending) { + throw new Error("remote workspace executor sent duplicate presence"); + } + const approved = parseRemoteWorkspaceCapabilities(this.options.capabilities); + const capabilities = parseRemoteWorkspaceCapabilities(message.capabilities.filter(capability => approved.includes(capability))); + this.presencePending = true; + const accept = () => { + if (this.closed) return; + this.options.onCapabilities?.(capabilities); + this.currentCapabilities = capabilities; + this.presenceAccepted = true; + this.presencePending = false; + }; + let sent: void | Promise; + try { + sent = this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence_ack", + capabilities, + })); + } catch (error) { + this.presencePending = false; + throw error; + } + if (sent && typeof sent.then === "function") { + void sent.then(accept).catch(() => this.close("remote workspace presence acknowledgement failed")); + } else { + accept(); + } + return; + } + if (!this.presenceAccepted) { + throw new Error("remote workspace executor presence is required before session traffic"); + } + if (message.type === "heartbeat") return; + if (message.type === "session_accept") { + const pending = this.pending.get(message.sessionId); + if (!pending) { + if (!this.cancelledSessionIds.delete(message.sessionId)) { + throw new Error("remote workspace accepted an unknown session"); + } + void Promise.resolve(this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId: message.sessionId, + reason: "remote workspace session was already cancelled", + }))).catch(() => this.close("remote workspace cancelled-session cleanup failed")); + return; + } + const cipher = pending.handshake.complete(message.hostHello, this.options.devicePublicKey); + const transport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: this.options.deviceId, + cipher, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.sessionId, + payload: value, + })), + }); + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + this.active.set(message.sessionId, transport); + pending.resolve(transport); + return; + } + if (message.type === "session_reject") { + const pending = this.pending.get(message.sessionId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + pending.reject(new Error(safeReason(message.reason))); + return; + } + const transport = this.active.get(message.sessionId); + if (!transport) throw new Error("remote workspace ciphertext targeted an unknown session"); + transport.receiveCiphertext(message.payload); + } + + async closeSession(sessionId: string, reason = "remote workspace session closed"): Promise { + const pending = this.pending.get(sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(sessionId); + this.rememberCancelledSession(sessionId); + pending.reject(new Error(safeReason(reason))); + } + const transport = this.active.get(sessionId); + if (transport) { + this.active.delete(sessionId); + transport.close(safeReason(reason)); + } + if (this.closed) return; + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId, + reason: safeReason(reason), + })); + } + + close(reason = "remote workspace executor disconnected"): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(safeReason(reason))); + } + this.pending.clear(); + for (const transport of this.active.values()) transport.close(safeReason(reason)); + this.active.clear(); + this.cancelledSessionIds.clear(); + try { this.options.socket.close(1008, safeReason(reason)); } catch { /* socket is already gone */ } + } + + private rememberCancelledSession(sessionId: string): void { + this.cancelledSessionIds.add(sessionId); + while (this.cancelledSessionIds.size > 16) { + const oldest = this.cancelledSessionIds.values().next(); + if (oldest.done) break; + this.cancelledSessionIds.delete(oldest.value); + } + } +} + +/** Executor-side connection. It owns no Codex, Claude Code, Pi, provider key, or model session. */ +export class RemoteWorkspaceExecutorAgentConnection { + private readonly sessions = new Map(); + private closed = false; + + constructor(private readonly options: { + deviceId: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + executor: RemoteWorkspaceExecutor; + capabilities?: readonly RemoteWorkspaceCapability[]; + onPresenceAccepted?: () => void; + socket: RemoteWorkspaceControlSocket; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + private currentCapabilities: RemoteWorkspaceCapability[]; + + async receive(raw: string | Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace agent connection is closed"); + const message = parseRemoteWorkspaceHubMessage(raw); + if (message.type === "presence_ack") { + if (message.capabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace Hub acknowledged different executor capabilities"); + } + this.currentCapabilities = [...message.capabilities]; + this.options.onPresenceAccepted?.(); + return; + } + if (message.type === "session_open") { + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint | null = null; + try { + if (this.sessions.has(message.clientHello.sessionId)) { + throw new Error("remote workspace executor session already exists"); + } + if (this.sessions.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (message.clientHello.deviceId !== this.options.deviceId) { + throw new Error("remote workspace session targeted another executor"); + } + if (!this.options.executor.hasApprovedRoot(message.rootId)) { + throw new Error("remote workspace root is not approved"); + } + const accepted = acceptRemoteControlClientHello(message.clientHello, { + expectedSessionId: message.clientHello.sessionId, + expectedDeviceId: this.options.deviceId, + accountPublicKey: this.options.hubPublicKey, + devicePrivateKey: this.options.deviceIdentity.privateKey, + allowedCapabilities: this.currentCapabilities, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: this.options.deviceId, + sessionId: message.clientHello.sessionId, + rootId: message.rootId, + capabilities: parseRemoteWorkspaceCapabilities(accepted.hello.capabilities), + cipher: accepted.cipher, + executor: this.options.executor, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.clientHello.sessionId, + payload: value, + })), + }); + this.sessions.set(message.clientHello.sessionId, endpoint); + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_accept", + sessionId: message.clientHello.sessionId, + hostHello: accepted.hello, + })); + } catch (error) { + if (endpoint) { + this.sessions.delete(message.clientHello.sessionId); + endpoint.close(); + } + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_reject", + sessionId: message.clientHello.sessionId, + reason: safeReason(error instanceof Error ? error.message : "remote workspace session refused"), + })); + } + return; + } + if (message.type === "session_close") { + this.sessions.get(message.sessionId)?.close(); + this.sessions.delete(message.sessionId); + return; + } + const endpoint = this.sessions.get(message.sessionId); + if (!endpoint) throw new Error("remote workspace ciphertext targeted an unknown executor session"); + // Decryption and counter validation happen synchronously before this returns. The execution + // promise is intentionally detached so an unencrypted session_close control frame can abort a + // long-running command instead of waiting behind that command on the socket's ordered queue. + void endpoint.receiveCiphertext(message.payload).catch(() => { + this.close(); + this.options.socket.close(1008, "remote workspace protocol error"); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const endpoint of this.sessions.values()) endpoint.close(); + this.sessions.clear(); + } +} diff --git a/src/remote-control/workspace-claude-runtime.ts b/src/remote-control/workspace-claude-runtime.ts new file mode 100644 index 0000000000..243ee5e1da --- /dev/null +++ b/src/remote-control/workspace-claude-runtime.ts @@ -0,0 +1,243 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { remoteWorkspaceDeveloperInstructions } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_OUTPUT_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function assistantText(value: unknown): string | null { + const message = record(value); + if (!message || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (retained >= MAX_STDERR_BYTES) continue; + const chunk = next.value.subarray(0, MAX_STDERR_BYTES - retained); + chunks.push(chunk); + retained += chunk.byteLength; + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(retained); + let offset = 0; + for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(merged); +} + +export interface ClaudeRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class ClaudeRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "claude" as const; + + constructor(private readonly options: ClaudeRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("claude"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Claude Code is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("claude"); + if (!executable) throw new Error("Claude Code is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-claude-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const mcpPath = join(isolation, "mcp.json"); + try { + writeFileSync(mcpPath, `${JSON.stringify({ + mcpServers: { + ocx_remote_workspace: { + type: "http", + url: `${bridge.url}/mcp`, + headers: { Authorization: `Bearer ${bridge.token}` }, + }, + }, + })}\n`, { mode: 0o600 }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let firstTurn = options.resumeThreadId === undefined; + let active: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null; + let stopped = false; + let stopOperation: Promise | null = null; + + const runPrompt = async (text: string): Promise => { + if (stopped) throw new Error("Claude Remote Workspace session is stopped"); + if (active) throw new Error("Claude Remote Workspace turn is already active"); + const args = [ + ...commandPrefix, + "-p", + "--input-format", "text", + "--output-format", "stream-json", + "--verbose", + "--strict-mcp-config", + "--mcp-config", mcpPath, + "--setting-sources", "", + "--tools", "", + "--allowedTools", "mcp__ocx_remote_workspace__*", + "--permission-mode", "dontAsk", + "--disable-slash-commands", + "--no-chrome", + "--system-prompt", remoteWorkspaceDeveloperInstructions(options.deviceName, options.tools), + firstTurn ? "--session-id" : "--resume", + threadId, + ]; + const childEnv = { ...process.env, ...this.options.env }; + const invocation = remoteWorkspaceProcessInvocation(args, { env: childEnv }); + const child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + active = child; + try { + child.stdin.write(text); + child.stdin.end(); + } catch (error) { + await stopRemoteWorkspaceProcess(child); + if (active === child) active = null; + throw error; + } + const stderrPromise = drain(child.stderr); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let emittedAssistant = false; + let resultError: string | null = null; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_OUTPUT_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Claude Code output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_OUTPUT_LINE_BYTES) throw new Error("Claude Code output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (event?.type === "assistant") { + const answer = assistantText(event.message); + if (answer) { options.emit("assistant", answer); emittedAssistant = true; } + } + if (event?.type === "result") { + if (event.is_error === true) resultError = safeError(event.result, "Claude Code turn failed"); + else if (!emittedAssistant && typeof event.result === "string" && event.result) { + options.emit("assistant", event.result); + emittedAssistant = true; + } + } + } + newline = buffer.indexOf("\n"); + } + } + const exitCode = await child.exited; + const stderr = await stderrPromise; + if (resultError) throw new Error(resultError); + if (exitCode !== 0) throw new Error(safeError(stderr, `Claude Code exited with code ${exitCode}`)); + firstTurn = false; + } catch (error) { + await stopRemoteWorkspaceProcess(child); + await stderrPromise.catch(() => ""); + throw error; + } finally { + reader.releaseLock(); + if (active === child) active = null; + } + }; + + return { + threadId, + canResume: () => !firstTurn, + prompt: runPrompt, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const child = active; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { if (child) await stopRemoteWorkspaceProcess(child); }, + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts new file mode 100644 index 0000000000..b064e3c9b8 --- /dev/null +++ b/src/remote-control/workspace-codex-runtime.ts @@ -0,0 +1,531 @@ +import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { resolveCodexRuntime } from "../codex/runtime"; +import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_WORKSPACE_TOOL_NAMESPACE } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { + codexRemotePermissionProfileCompatibility, + resolveCodexLinuxSandboxBinary, +} from "./workspace-codex-sandbox"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceSessionEvent, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; +const MAX_BUFFERED_ASSISTANT_ITEMS = 32; +const MAX_BUFFERED_ASSISTANT_BYTES = 64 * 1024; +const MAX_EARLY_TURN_COMPLETIONS = 16; +const START_TIMEOUT_MS = 15_000; +const REQUEST_TIMEOUT_MS = 60_000; + +interface JsonRpcMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: { message?: unknown }; +} + +interface PendingRpc { + resolve(message: JsonRpcMessage): void; + reject(error: Error): void; + timer: ReturnType; +} + +function parseJsonRpcMessage(value: unknown): JsonRpcMessage { + const raw = object(value); + if (!raw) throw new Error("invalid Codex App Server message"); + if (raw.id !== undefined && typeof raw.id !== "string" && typeof raw.id !== "number") { + throw new Error("invalid Codex App Server message ID"); + } + if (raw.method !== undefined && typeof raw.method !== "string") { + throw new Error("invalid Codex App Server method"); + } + const params = raw.params === undefined ? undefined : object(raw.params); + const result = raw.result === undefined ? undefined : object(raw.result); + const error = raw.error === undefined ? undefined : object(raw.error); + if ((raw.params !== undefined && !params) + || (raw.result !== undefined && !result) + || (raw.error !== undefined && !error)) { + throw new Error("invalid Codex App Server message fields"); + } + return { + ...(raw.id !== undefined ? { id: raw.id } : {}), + ...(typeof raw.method === "string" ? { method: raw.method } : {}), + ...(params ? { params } : {}), + ...(result ? { result } : {}), + ...(error ? { error: { message: error.message } } : {}), + }; +} + +function errorMessage(value: unknown, fallback: string): string { + const raw = value instanceof Error ? value.message : typeof value === "string" ? value : fallback; + return raw.replace(/[^\x20-\x7e\n\t]/g, " ").slice(0, 4_096) || fallback; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function nestedString(value: unknown, keys: readonly string[]): string | null { + let current: unknown = value; + for (const key of keys) current = object(current)?.[key]; + return typeof current === "string" && current.length > 0 ? current : null; +} + +function itemText(value: unknown): string | null { + const item = object(value); + if (!item) return null; + if (typeof item.text === "string" && item.text.length > 0) return item.text; + if (!Array.isArray(item.content)) return null; + const parts: string[] = []; + for (const raw of item.content) { + const part = object(raw); + const text = part && typeof part.text === "string" ? part.text : null; + if (text) parts.push(text); + } + return parts.length > 0 ? parts.join("") : null; +} + +function appendBoundedUtf8(current: string, delta: string, maximum: number): string { + const marker = "\n[truncated]"; + if (current.endsWith(marker)) return current; + const combined = `${current}${delta}`; + if (Buffer.byteLength(combined, "utf8") <= maximum) return combined; + const bodyLimit = maximum - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(combined, bodyLimit)}${marker}`; +} + +function setBounded(map: Map, key: K, value: V, maximum: number): void { + if (!map.has(key) && map.size >= maximum) { + const oldest = map.keys().next(); + if (!oldest.done) map.delete(oldest.value); + } + map.set(key, value); +} + +class JsonLineRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private closeError: Error | null = null; + + onRequest: ((message: JsonRpcMessage) => Promise) | null = null; + onNotification: ((message: JsonRpcMessage) => void) | null = null; + onClose: ((error: Error) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.readStdout(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Codex App Server exited with code ${code}`))); + } + + request(method: string, params: Record, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + if (this.closed) return Promise.reject(this.closeError ?? new Error("Codex App Server is closed")); + const id = ++this.nextId; + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex App Server ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Codex App Server write failed")); + } + } + return result; + } + + notify(method: string, params: Record): void { + this.send({ jsonrpc: "2.0", method, params }); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* child already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Pending callers must settle even if the OS refuses to reap the child. + this.fail(new Error("Codex App Server session closed")); + } + } + + private send(message: Record): void { + if (this.closed) throw this.closeError ?? new Error("Codex App Server is closed"); + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async readStdout(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Codex App Server output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server output line is too large"); + if (line) this.receive(parseJsonRpcMessage(JSON.parse(line))); + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(errorMessage(error, "Codex App Server output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + retained = Math.min(MAX_STDERR_BYTES, retained + next.value.byteLength); + } + } catch { + // stdout and the exit code own the user-visible process failure. + } finally { + reader.releaseLock(); + void retained; + } + } + + private receive(message: JsonRpcMessage): void { + if (!message || typeof message !== "object") throw new Error("invalid Codex App Server message"); + if (message.id !== undefined && typeof message.method !== "string") { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(errorMessage(message.error.message, "Codex App Server request failed"))); + else pending.resolve(message); + return; + } + if (typeof message.method !== "string") return; + if (message.id === undefined) { + this.onNotification?.(message); + return; + } + const id = message.id; + const request = this.onRequest; + if (!request) { + this.send({ jsonrpc: "2.0", id, error: { code: -32_601, message: "client request handler is unavailable" } }); + return; + } + void request(message).then( + response => this.send({ jsonrpc: "2.0", ...response }), + error => this.send({ + jsonrpc: "2.0", + id, + error: { code: -32_000, message: errorMessage(error, "Remote Workspace tool failed") }, + }), + ); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.closeError = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.onClose?.(error); + } +} + +interface ActiveTurn { + id: string; + resolve(): void; + reject(error: Error): void; +} + +export interface CodexRemoteWorkspaceRuntimeOptions { + /** Test seam. Production resolves the configured, trusted Codex runtime. */ + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "codex" as const; + + constructor(private readonly options: CodexRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + if (this.options.command && this.options.command.length > 0) { + return { available: true, version: this.options.version ?? "test" }; + } + const resolved = resolveCodexRuntime(); + const compatibility = codexRemotePermissionProfileCompatibility(); + if (!compatibility.compatible) return { available: false, reason: compatibility.reason }; + return resolved.runtime.version + ? { available: true, version: resolved.runtime.version } + : { available: false, reason: "Codex CLI is not installed or runnable on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const command = this.options.command + ? [...this.options.command] + : [resolveCodexRuntime().runtime.command]; + if (command.length < 1) throw new Error("Codex CLI is unavailable on this Hub"); + const executablePath = isAbsolute(command[0]!) ? command[0]! : findExecutableOnPath(command[0]!); + if (!executablePath) throw new Error("Codex CLI executable could not be resolved on this Hub"); + command[0] = executablePath; + const runtimeDirectory = dirname(realpathSync(executablePath)); + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-codex-")); + let processPath = process.env.PATH ?? "/usr/bin:/bin"; + const runtimeReadPaths = [runtimeDirectory]; + try { + chmodSync(isolation, 0o700); + if (process.platform === "linux") { + const native = resolveCodexLinuxSandboxBinary(executablePath); + if (!native) { + throw new Error("Codex Remote Workspace could not locate the native Linux permission-profile helper"); + } + const helperDir = join(isolation, "sandbox-bin"); + mkdirSync(helperDir, { mode: 0o700 }); + const helper = join(helperDir, "codex-linux-sandbox"); + try { linkSync(native, helper); } + catch { symlinkSync(native, helper); } + processPath = `${helperDir}:${processPath}`; + runtimeReadPaths.push(dirname(native), helperDir); + } + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const thread = { id: "" }; + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId: () => thread.id, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const tokenEnvVar = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + const childEnv = { ...process.env, ...this.options.env, PATH: processPath, [tokenEnvVar]: bridge.token }; + const invocation = remoteWorkspaceProcessInvocation([ + ...command, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const peer = new JsonLineRpcProcess(child); + let activeTurn: ActiveTurn | null = null; + let stopped = false; + const completedBeforeWait = new Map(); + const assistantDeltas = new Map(); + let stopOperation: Promise | null = null; + + const finishTurn = (turnId: string, status: string, detail: string | null): void => { + if (!activeTurn || activeTurn.id !== turnId) { + setBounded(completedBeforeWait, turnId, { status, error: detail }, MAX_EARLY_TURN_COMPLETIONS); + return; + } + const current = activeTurn; + activeTurn = null; + assistantDeltas.clear(); + if (status === "completed") current.resolve(); + else current.reject(new Error(detail ?? `Codex turn ${status}`)); + }; + + peer.onRequest = async message => { + if (message.method !== "item/tool/call" || message.id === undefined) { + throw new Error("unsupported Codex App Server client request"); + } + const tool = nestedString(message.params, ["tool"]) ?? "remote tool"; + options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`); + return options.coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + }; + peer.onNotification = message => { + const params = message.params ?? {}; + if (message.method === "item/agentMessage/delta") { + const itemId = nestedString(params, ["itemId"]) ?? nestedString(params, ["item", "id"]); + const delta = nestedString(params, ["delta"]); + if (itemId && delta) { + setBounded( + assistantDeltas, + itemId, + appendBoundedUtf8(assistantDeltas.get(itemId) ?? "", delta, MAX_BUFFERED_ASSISTANT_BYTES), + MAX_BUFFERED_ASSISTANT_ITEMS, + ); + } + return; + } + if (message.method === "item/completed") { + const item = object(params.item); + const itemId = item && typeof item.id === "string" ? item.id : null; + const text = itemText(item) ?? (itemId ? assistantDeltas.get(itemId) ?? null : null); + if (itemId) assistantDeltas.delete(itemId); + if (text) options.emit("assistant", text); + return; + } + if (message.method === "turn/completed") { + const turn = object(params.turn); + const turnId = turn && typeof turn.id === "string" ? turn.id : null; + if (!turnId) return; + const status = typeof turn?.status === "string" ? turn.status : "failed"; + const detail = nestedString(turn, ["error", "message"]); + finishTurn(turnId, status, detail); + } + }; + peer.onClose = error => { + const current = activeTurn; + activeTurn = null; + completedBeforeWait.clear(); + assistantDeltas.clear(); + current?.reject(error); + }; + + try { + await peer.request("initialize", { + clientInfo: { name: "opencodex_remote_workspace", title: "OpenCodex Remote Workspace", version: "1" }, + capabilities: { experimentalApi: true }, + }, START_TIMEOUT_MS); + peer.notify("initialized", {}); + const effective = await peer.request("config/read", { cwd: isolation, includeLayers: false }, START_TIMEOUT_MS); + const effectiveConfig = object(effective.result?.config) ?? {}; + if (typeof effectiveConfig.sandbox_mode === "string" || effectiveConfig.sandbox_workspace_write) { + throw new Error("Codex Remote Workspace requires permission profiles; remove legacy sandbox_mode settings from the selected Codex profile first"); + } + const disabledServerNames = Object.keys(object(effectiveConfig.mcp_servers) ?? {}); + const disabledHookNames = Object.keys(object(effectiveConfig.hooks) ?? {}); + const threadParams = remoteWorkspaceThreadStartParams({ + executorName: options.deviceName, + coordinatorIsolationPath: isolation, + tools: options.tools, + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: tokenEnvVar, + disabledServerNames, + disabledHookNames, + hubRuntimeReadPaths: runtimeReadPaths, + }, + }); + const { ephemeral: _startOnlyEphemeral, ...resumeParams } = threadParams; + const started = options.resumeThreadId + ? await peer.request("thread/resume", { ...resumeParams, threadId: options.resumeThreadId }, START_TIMEOUT_MS) + : await peer.request("thread/start", threadParams, START_TIMEOUT_MS); + const threadId = nestedString(started.result, ["thread", "id"]); + if (!threadId) throw new Error("Codex App Server returned no thread ID"); + if (options.resumeThreadId && threadId !== options.resumeThreadId) { + throw new Error("Codex App Server resumed a different Remote Workspace thread"); + } + thread.id = threadId; + + return { + threadId, + async prompt(text: string): Promise { + if (stopped) throw new Error("Codex Remote Workspace session is stopped"); + if (activeTurn) throw new Error("Codex Remote Workspace turn is already active"); + const startedTurn = await peer.request("turn/start", { + threadId, + input: [{ type: "text", text }], + approvalPolicy: "never", + }); + const turnId = nestedString(startedTurn.result, ["turn", "id"]); + if (!turnId) throw new Error("Codex App Server returned no turn ID"); + const early = completedBeforeWait.get(turnId); + if (early) { + completedBeforeWait.delete(turnId); + if (early.status === "completed") return; + throw new Error(early.error ?? `Codex turn ${early.status}`); + } + await new Promise((resolve, reject) => { activeTurn = { id: turnId, resolve, reject }; }); + }, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const turn = activeTurn; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { + if (turn) await peer.request("turn/interrupt", { threadId, turnId: turn.id }, 3_000).catch(() => {}); + }, + () => peer.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } catch (error) { + await peer.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + } +} diff --git a/src/remote-control/workspace-codex-sandbox.ts b/src/remote-control/workspace-codex-sandbox.ts new file mode 100644 index 0000000000..21630db764 --- /dev/null +++ b/src/remote-control/workspace-codex-sandbox.ts @@ -0,0 +1,115 @@ +import { accessSync, constants, existsSync, openSync, closeSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { findExecutableOnPath } from "./workspace-executable"; +import { resolveCodexHomeDir } from "../codex/home"; + +function isNativeExecutable(path: string): boolean { + let descriptor: number | null = null; + try { + descriptor = openSync(path, "r"); + const header = Buffer.alloc(4); + if (readSync(descriptor, header, 0, header.length, 0) !== header.length) return false; + return header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + } catch { + return false; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +function packageRootForEntrypoint(path: string): string | null { + let current = dirname(path); + for (let depth = 0; depth < 10; depth += 1) { + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + try { + const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }; + if (parsed.name === "@openai/codex") return current; + } catch { /* keep walking */ } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function checkedNative(path: string): string | null { + try { + const canonical = realpathSync(path); + if (!statSync(canonical).isFile() || !isNativeExecutable(canonical)) return null; + accessSync(canonical, constants.X_OK); + return canonical; + } catch { + return null; + } +} + +function generatedShimBacking(path: string): string | null { + try { + const source = readFileSync(path, "utf8"); + if (Buffer.byteLength(source, "utf8") > 128 * 1024 + || !source.includes("# opencodex codex autostart shim")) return null; + const match = /^exec '([^'\r\n]+)' "\$@"\s*$/m.exec(source); + return match?.[1] && isAbsolute(match[1]) ? match[1] : null; + } catch { + return null; + } +} + +/** + * Permission profiles invoke the same native Codex binary under argv[0] + * `codex-linux-sandbox`. npm and OpenCodex shims expose a JS/shell launcher instead, + * so resolve the package-owned native binary without executing or modifying the install. + */ +export function resolveCodexLinuxSandboxBinary(command: string): string | null { + if (process.platform !== "linux") return null; + const selected = isAbsolute(command) ? command : findExecutableOnPath(command); + if (!selected) return null; + const shim = inspectCodexShimBackingForCommand(selected); + const entrypoint = shim.status === "matched" + ? shim.backingPath + : generatedShimBacking(selected) ?? selected; + const direct = checkedNative(entrypoint); + if (direct) return direct; + let canonical: string; + try { canonical = realpathSync(entrypoint); } catch { return null; } + const root = packageRootForEntrypoint(canonical); + if (!root) return null; + const target = arch() === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; + const packageName = arch() === "arm64" ? "codex-linux-arm64" : "codex-linux-x64"; + const candidates = [ + join(root, "node_modules", "@openai", packageName, "vendor", target, "bin", "codex"), + join(root, "vendor", target, "bin", "codex"), + ]; + for (const candidate of candidates) { + const native = checkedNative(candidate); + if (native) return native; + } + return null; +} + +export function codexRemotePermissionProfileCompatibility( + codexHome = resolveCodexHomeDir(), +): { compatible: boolean; reason?: string } { + const configPath = join(codexHome, "config.toml"); + if (!existsSync(configPath)) return { compatible: true }; + try { + const metadata = statSync(configPath); + if (!metadata.isFile() || metadata.size > 4 * 1024 * 1024) { + return { compatible: false, reason: "Codex config cannot be safely inspected for Remote Workspace permissions." }; + } + const config = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + if (typeof config.sandbox_mode === "string" || config.sandbox_workspace_write !== undefined) { + return { + compatible: false, + reason: "Codex Remote Workspace needs permission profiles, but this Codex config still selects legacy sandbox_mode.", + }; + } + return { compatible: true }; + } catch { + return { compatible: false, reason: "Codex config could not be parsed for Remote Workspace permissions." }; + } +} diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts new file mode 100644 index 0000000000..f9a3625caa --- /dev/null +++ b/src/remote-control/workspace-command-runner.ts @@ -0,0 +1,749 @@ +import { createHash } from "node:crypto"; +import { + accessSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + opendirSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import type { + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; + +const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const NATIVE_HELPER_PROTOCOL_VERSION = 1; +const MAX_NATIVE_HELPER_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_HELPER_ERROR_CHARS = 512; +const MAX_NATIVE_HELPER_STDERR_BYTES = 16 * 1024; +const MAX_WORKSPACE_PREFLIGHT_ENTRIES = 250_000; +const SANDBOX_BUN_PATH = "/ocx-runtime/bin/bun"; +const READABLE_SYSTEM_PATHS = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", +] as const; +const READABLE_ETC_PATHS = [ + "/etc/alternatives", + "/etc/ca-certificates", + "/etc/ssl", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/localtime", + "/etc/resolv.conf", +] as const; + +export interface LinuxRemoteWorkspaceCommandRunnerOptions { + bubblewrapPath?: string; + networkAccess?: boolean; + /** Additional read-only toolchain trees explicitly approved by the device owner. */ + toolchainRoots?: readonly string[]; + /** Exact Bun executable used by OCX; mounted as one file rather than exposing its host directory. */ + runtimeExecutablePath?: string; + /** Writable roots inspected before command capability is advertised. */ + writableRoots?: readonly string[]; + spawn?: typeof Bun.spawn; + /** Cross-platform test seam for the real namespace capability probe. */ + probe?: (argv: readonly string[]) => boolean; +} + +export interface RemoteWorkspaceNativeHelperDescriptor { + path: string; + sha256: string; +} + +interface NativeHelperRequest { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + operation: "probe" | "run"; + root?: string; + cwd?: string; + command?: string[]; + toolchainRoots?: string[]; + timeoutMs?: number; + maxOutputBytes?: number; + networkAccess?: boolean; +} + +interface NativeHelperProbeResponse { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + ok: true; + probe: true; +} + +export interface NativeRemoteWorkspaceCommandRunnerOptions { + helper: RemoteWorkspaceNativeHelperDescriptor; + toolchainRoots?: readonly string[]; + /** Writable workspace roots that must never contain the executable enforcing their sandbox. */ + writableRoots: readonly string[]; + networkAccess?: boolean; + platform?: NodeJS.Platform; + spawn?: typeof Bun.spawn; + spawnSync?: typeof Bun.spawnSync; + /** Pure test seam. Production always executes the digest-pinned helper's real probe. */ + probe?: (request: NativeHelperRequest) => unknown; +} + +const availabilityCache = new Map(); + +function exactObject(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace native helper returned an invalid response"); + } + const record = value as Record; + const allowed = new Set(keys); + if (Object.keys(record).some(key => !allowed.has(key))) { + throw new Error("remote workspace native helper returned an invalid response"); + } + return record; +} + +function parseNativeHelperProbeResponse(value: unknown): NativeHelperProbeResponse { + const raw = exactObject(value, ["version", "ok", "probe"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true || raw.probe !== true) { + throw new Error("remote workspace native helper failed its confinement probe"); + } + return { version: NATIVE_HELPER_PROTOCOL_VERSION, ok: true, probe: true }; +} + +function boundedBase64(value: unknown, label: string, maximum: number): Buffer { + if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4 + 4 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength > maximum || decoded.toString("base64") !== value) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + return decoded; +} + +function parseNativeHelperCommandResponse(value: unknown, maximum: number): RemoteWorkspaceCommandResult { + const raw = exactObject(value, ["version", "ok", "exitCode", "stdoutBase64", "stderrBase64"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true + || typeof raw.exitCode !== "number" || !Number.isSafeInteger(raw.exitCode) + || raw.exitCode < -2_147_483_648 || raw.exitCode > 4_294_967_295) { + throw new Error("remote workspace native helper returned an invalid command result"); + } + const stdout = boundedBase64(raw.stdoutBase64, "stdout", maximum); + const stderr = boundedBase64(raw.stderrBase64, "stderr", maximum); + if (stdout.byteLength + stderr.byteLength > maximum) { + throw new Error("remote workspace native helper exceeded its output contract"); + } + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { + exitCode: raw.exitCode, + stdout: decoder.decode(stdout), + stderr: decoder.decode(stderr), + }; +} + +function parseNativeHelperJson(value: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("remote workspace native helper returned malformed JSON"); + } +} + +function sha256File(path: string): string { + const descriptor = openSync(path, constants.O_RDONLY); + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_NATIVE_HELPER_BYTES) { + throw new Error("remote workspace native helper has an invalid size"); + } + const hash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < metadata.size) { + const count = readSync(descriptor, chunk, 0, Math.min(chunk.byteLength, metadata.size - offset), offset); + if (count === 0) throw new Error("remote workspace native helper changed while hashing"); + hash.update(chunk.subarray(0, count)); + offset += count; + } + const after = fstatSync(descriptor); + if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs + || after.dev !== metadata.dev || after.ino !== metadata.ino) { + throw new Error("remote workspace native helper changed while hashing"); + } + return hash.digest("hex"); + } finally { + closeSync(descriptor); + } +} + +export function pinRemoteWorkspaceNativeHelper(path: string): RemoteWorkspaceNativeHelperDescriptor { + if (!isAbsolute(path) || path.includes("\0")) { + throw new Error("remote workspace native helper must be an absolute path"); + } + const linked = lstatSync(path); + if (!linked.isFile() || linked.isSymbolicLink()) { + throw new Error("remote workspace native helper must remain a real file"); + } + const canonical = realpathSync(path); + accessSync(canonical, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (statSync(canonical).mode & 0o022) !== 0) { + throw new Error("remote workspace native helper must not be group or world writable"); + } + return { path: canonical, sha256: sha256File(canonical) }; +} + +export function discoverRemoteWorkspaceNativeHelper(options: { + platform?: NodeJS.Platform; + architecture?: string; +} = {}): RemoteWorkspaceNativeHelperDescriptor | undefined { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") return undefined; + const architecture = options.architecture ?? arch(); + const executable = platform === "win32" + ? "opencodex-remote-workspace-helper.exe" + : "opencodex-remote-workspace-helper"; + const candidates = [ + // Signed release bundles place the helper here. + `${import.meta.dir}/../../native-bin/${platform}-${architecture}/${executable}`, + // Source/private-dogfood builds produced by `bun run build:remote-workspace-helper`. + `${import.meta.dir}/../../native/remote-workspace-helper/target/release/${executable}`, + ]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + return pinRemoteWorkspaceNativeHelper(candidate); + } catch { + return undefined; + } + } + return undefined; +} + +export function parseRemoteWorkspaceNativeHelperDescriptor(value: unknown): RemoteWorkspaceNativeHelperDescriptor { + const raw = exactObject(value, ["path", "sha256"]); + if (typeof raw.path !== "string" || !isAbsolute(raw.path) || raw.path.includes("\0") || raw.path.length > 4096 + || typeof raw.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(raw.sha256)) { + throw new Error("invalid remote workspace native helper descriptor"); + } + return { path: raw.path, sha256: raw.sha256 }; +} + +function assertNativeHelperIntegrity(value: RemoteWorkspaceNativeHelperDescriptor): RemoteWorkspaceNativeHelperDescriptor { + const helper = parseRemoteWorkspaceNativeHelperDescriptor(value); + const linked = lstatSync(helper.path); + if (!linked.isFile() || linked.isSymbolicLink() || realpathSync(helper.path) !== helper.path) { + throw new Error("remote workspace native helper identity changed; pair it again"); + } + accessSync(helper.path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (linked.mode & 0o022) !== 0) { + throw new Error("remote workspace native helper permissions are unsafe"); + } + if (sha256File(helper.path) !== helper.sha256) { + throw new Error("remote workspace native helper digest changed; pair it again"); + } + return helper; +} + +function assertNativeHelperOutsideWritableRoots( + helper: RemoteWorkspaceNativeHelperDescriptor, + roots: readonly string[], +): string[] { + if (roots.length < 1 || roots.length > 32) { + throw new Error("remote workspace native runner needs one to 32 writable roots"); + } + const canonicalRoots: string[] = []; + for (const root of roots) { + if (!isAbsolute(root) || root.includes("\0")) { + throw new Error("remote workspace writable root must be an absolute path"); + } + const canonicalRoot = realpathSync(root); + if (canonicalRoots.includes(canonicalRoot)) { + throw new Error("remote workspace native runner received a duplicate writable root"); + } + if (inside(canonicalRoot, helper.path)) { + // A sandboxed command can write anywhere below its approved root. Executing the sandbox + // helper from that same tree would turn the hash-then-spawn pathname into a writable trust + // anchor that a workspace command can replace before a later invocation. + throw new Error("remote workspace native helper must be outside every writable workspace root"); + } + canonicalRoots.push(canonicalRoot); + } + return canonicalRoots; +} + +function nativeHelperEnvironment(platform: NodeJS.Platform): Record { + const result: Record = {}; + const names = platform === "win32" + ? ["SystemRoot", "WINDIR", "TEMP", "TMP"] + : ["TMPDIR"]; + for (const name of names) { + const value = process.env[name]; + if (value) result[name] = value; + } + return result; +} + +function inside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function assertWorkspaceHasNoExternalHardlinkAliases(root: string): void { + const canonicalRoot = realpathSync(root); + const pending = [canonicalRoot]; + let entries = 0; + while (pending.length > 0) { + const current = pending.pop()!; + const directory = opendirSync(current); + try { + for (;;) { + const entry = directory.readSync(); + if (!entry) break; + entries += 1; + if (entries > MAX_WORKSPACE_PREFLIGHT_ENTRIES) { + throw new Error("remote workspace is too large for safe command preflight"); + } + const target = join(current, entry.name); + const metadata = lstatSync(target); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + pending.push(target); + } else if (!metadata.isDirectory() && metadata.nlink > 1) { + // A bind mount or Seatbelt path rule cannot distinguish two names for one inode. Reject + // rather than let a workspace alias read or mutate a file whose other name is outside. + throw new Error("remote workspace command root contains a hard-linked file"); + } + } + } finally { + directory.closeSync(); + } + } +} + +function assertCommandRootsSafe(roots: readonly string[]): void { + for (const root of roots) assertWorkspaceHasNoExternalHardlinkAliases(root); +} + +function sandboxPath(root: string, cwd: string): string { + if (!inside(root, cwd)) throw new Error("remote workspace command cwd escaped its root"); + const rel = relative(root, cwd); + return rel ? `/workspace/${rel.split(sep).join("/")}` : "/workspace"; +} + +function bindArgs(flag: "--ro-bind" | "--ro-bind-try", paths: readonly string[]): string[] { + const result: string[] = []; + for (const path of paths) { + if (flag === "--ro-bind-try" || existsSync(path)) result.push(flag, path, path); + } + return result; +} + +function approvedToolchainRoots(values: readonly string[]): string[] { + const result: string[] = []; + for (const value of values) { + if (!isAbsolute(value) || !existsSync(value) || value.includes("\0")) { + throw new Error("remote workspace toolchain root must be an existing absolute path"); + } + const metadata = lstatSync(value); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must remain a real directory"); + } + result.push(realpathSync(value)); + } + return [...new Set(result)]; +} + +function approvedRuntimeExecutable(value: string | undefined): string | null { + if (value === undefined) return null; + if (!isAbsolute(value) || value.includes("\0")) { + throw new Error("remote workspace runtime executable must be an absolute path"); + } + const canonical = realpathSync(value); + if (!statSync(canonical).isFile()) throw new Error("remote workspace runtime executable must be a file"); + accessSync(canonical, constants.X_OK); + return canonical; +} + +function trustedBubblewrap(path: string, roots: readonly string[]): string { + if (!isAbsolute(path)) throw new Error("bubblewrap must be an absolute executable path"); + const canonical = realpathSync(path); + const file = lstatSync(canonical); + if (!file.isFile() || file.nlink !== 1) throw new Error("bubblewrap must be a private executable file"); + for (const root of roots) { + if (inside(realpathSync(root), canonical)) { + throw new Error("bubblewrap must be outside every writable workspace root"); + } + } + accessSync(canonical, constants.X_OK); + let current = canonical; + for (;;) { + const metadata = lstatSync(current); + if (process.platform !== "win32" && (metadata.mode & 0o022) !== 0) { + throw new Error("bubblewrap executable and parent directories must not be group or world writable"); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return canonical; +} + +export function linuxRemoteWorkspaceCommandArgv( + request: RemoteWorkspaceCommandRequest, + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): string[] { + const bubblewrap = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", [...(options.writableRoots ?? []), request.root]); + const toolchains = approvedToolchainRoots(options.toolchainRoots ?? []); + const runtimeExecutable = approvedRuntimeExecutable(options.runtimeExecutablePath); + const commandPath = [...(runtimeExecutable ? ["/ocx-runtime/bin"] : []), ...toolchains, DEFAULT_PATH].join(":"); + return [ + bubblewrap, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ...(runtimeExecutable ? [ + "--dir", "/ocx-runtime", + "--dir", "/ocx-runtime/bin", + "--ro-bind", runtimeExecutable, SANDBOX_BUN_PATH, + ] : []), + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + ...bindArgs("--ro-bind-try", READABLE_ETC_PATHS), + ...toolchains.flatMap(path => ["--ro-bind", path, path]), + "--bind", request.root, "/workspace", + "--chdir", sandboxPath(request.root, request.cwd), + "--clearenv", + "--setenv", "HOME", "/workspace", + "--setenv", "PATH", commandPath, + "--setenv", "LANG", "C.UTF-8", + "--setenv", "LC_ALL", "C.UTF-8", + "--", + ...request.command, + ]; +} + +async function collectBoundedOutput( + stream: ReadableStream, + reserve: (bytes: number) => boolean, + onOverflow: () => void, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (!reserve(next.value.byteLength)) { + onOverflow(); + throw new Error("remote workspace command output limit exceeded"); + } + chunks.push(next.value); + total += next.value.byteLength; + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: false }).decode(body); +} + +export function createLinuxRemoteWorkspaceCommandRunner( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): RemoteWorkspaceCommandRunner { + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + assertWorkspaceHasNoExternalHardlinkAliases(request.root); + const argv = linuxRemoteWorkspaceCommandArgv(request, options); + const child = spawn(argv, { + cwd: request.root, + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let retained = 0; + let timedOut = false; + let overflowed = false; + let cancelled = false; + const stop = () => { + try { child.kill(); } catch { /* process already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const reserve = (bytes: number): boolean => { + if (retained + bytes > request.maxOutputBytes) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs); + try { + const [stdoutResult, stderrResult, exitCode] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace command timed out"); + if (overflowed) throw new Error("remote workspace command output limit exceeded"); + if (stdoutResult.status === "rejected") throw stdoutResult.reason; + if (stderrResult.status === "rejected") throw stderrResult.reason; + if (exitCode.status === "rejected") throw exitCode.reason; + return { exitCode: exitCode.value, stdout: stdoutResult.value, stderr: stderrResult.value }; + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + } + }, + }; +} + +function nativeHelperFailure(value: unknown): Error { + const raw = exactObject(value, ["version", "ok", "error"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== false + || typeof raw.error !== "string" || raw.error.length < 1 + || [...raw.error].length > MAX_NATIVE_HELPER_ERROR_CHARS || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(raw.error)) { + return new Error("remote workspace native helper returned an invalid failure"); + } + return new Error(raw.error); +} + +function nativeHelperRequest(options: NativeRemoteWorkspaceCommandRunnerOptions, request: RemoteWorkspaceCommandRequest): NativeHelperRequest { + return { + version: NATIVE_HELPER_PROTOCOL_VERSION, + operation: "run", + root: request.root, + cwd: request.cwd, + command: [...request.command], + toolchainRoots: approvedToolchainRoots(options.toolchainRoots ?? []), + timeoutMs: request.timeoutMs, + maxOutputBytes: request.maxOutputBytes, + networkAccess: options.networkAccess === true, + }; +} + +export function createNativeRemoteWorkspaceCommandRunner( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): RemoteWorkspaceCommandRunner { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") { + throw new Error("remote workspace native command helper is supported only on macOS and Windows"); + } + if (!nativeRemoteWorkspaceCommandRunnerAvailable(options)) { + throw new Error("remote workspace native command helper failed its confinement probe"); + } + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + const requestRoot = realpathSync(request.root); + if (!writableRoots.includes(requestRoot)) { + throw new Error("remote workspace command root is outside the native runner grant"); + } + assertWorkspaceHasNoExternalHardlinkAliases(requestRoot); + const body = JSON.stringify(nativeHelperRequest(options, request)); + if (Buffer.byteLength(body, "utf8") > 64 * 1024) { + throw new Error("remote workspace native helper request is too large"); + } + const child = spawn([helper.path], { + cwd: request.root, + env: nativeHelperEnvironment(platform), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }); + let retained = 0; + let overflowed = false; + let cancelled = false; + let timedOut = false; + const stop = () => { + try { child.kill(); } catch { /* helper already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const maximumResponseBytes = Math.ceil(request.maxOutputBytes / 3) * 4 + 4_096; + const reserve = (bytes: number): boolean => { + if (retained + bytes > maximumResponseBytes + MAX_NATIVE_HELPER_STDERR_BYTES) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs + 2_000); + try { + if (!cancelled) { + child.stdin.write(body); + child.stdin.end(); + } + const [stdoutResult, stderrResult, exitResult] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace native helper timed out"); + if (overflowed) throw new Error("remote workspace native helper output limit exceeded"); + if (stdoutResult.status === "rejected" || stderrResult.status === "rejected" || exitResult.status === "rejected") { + throw new Error("remote workspace native helper failed"); + } + if (Buffer.byteLength(stdoutResult.value, "utf8") > maximumResponseBytes + || Buffer.byteLength(stderrResult.value, "utf8") > MAX_NATIVE_HELPER_STDERR_BYTES + || exitResult.value !== 0) { + throw new Error("remote workspace native helper failed"); + } + const response = parseNativeHelperJson(Buffer.from(stdoutResult.value, "utf8")); + if (response && typeof response === "object" && !Array.isArray(response) + && (response as Record).ok === false) { + throw nativeHelperFailure(response); + } + return parseNativeHelperCommandResponse(response, request.maxOutputBytes); + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + try { child.stdin.end(); } catch { /* helper already closed stdin */ } + } + }, + }; +} + +export function nativeRemoteWorkspaceCommandRunnerAvailable( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): boolean { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") return false; // Windows awaits a surviving native cleanup owner. + try { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + assertCommandRootsSafe(writableRoots); + const request: NativeHelperRequest = { version: NATIVE_HELPER_PROTOCOL_VERSION, operation: "probe" }; + const raw = options.probe + ? options.probe(request) + : (() => { + const result = (options.spawnSync ?? Bun.spawnSync)([helper.path], { + cwd: dirname(helper.path), + env: nativeHelperEnvironment(platform), + stdin: Buffer.from(JSON.stringify(request), "utf8"), + stdout: "pipe", + stderr: "ignore", + timeout: 8_000, + windowsHide: true, + }); + if (!result.success || result.stdout.byteLength > 4_096) { + throw new Error("remote workspace native helper probe failed"); + } + return parseNativeHelperJson(result.stdout); + })(); + parseNativeHelperProbeResponse(raw); + return true; + } catch { + return false; + } +} + +export function createPlatformRemoteWorkspaceCommandRunner(options: { + platform?: NodeJS.Platform; + linux?: LinuxRemoteWorkspaceCommandRunnerOptions; + native?: Omit; +} = {}): RemoteWorkspaceCommandRunner | undefined { + const platform = options.platform ?? process.platform; + if (platform === "linux" && linuxRemoteWorkspaceCommandRunnerAvailable(options.linux)) { + const linux = { + ...options.linux, + runtimeExecutablePath: options.linux?.runtimeExecutablePath ?? process.execPath, + }; + return createLinuxRemoteWorkspaceCommandRunner(linux); + } + if ((platform === "darwin" || platform === "win32") && options.native) { + const native = { ...options.native, platform }; + try { + return createNativeRemoteWorkspaceCommandRunner(native); + } catch { + return undefined; + } + } + return undefined; +} + +export function linuxRemoteWorkspaceCommandRunnerAvailable( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): boolean { + let path: string; + try { + path = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", options.writableRoots ?? []); + if (options.writableRoots) assertCommandRootsSafe(options.writableRoots); + } catch { + return false; + } + const argv = [ + path, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + "--", + "/bin/true", + ]; + if (options.probe) return options.probe(argv); + const cacheKey = `${path}\0${options.networkAccess === true ? "network" : "isolated"}`; + const cached = availabilityCache.get(cacheKey); + if (cached !== undefined) return cached; + let available = false; + try { + available = Bun.spawnSync(argv, { + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + timeout: 2_000, + }).success; + } catch { + available = false; + } + availabilityCache.set(cacheKey, available); + return available; +} + diff --git a/src/remote-control/workspace-coordinator.ts b/src/remote-control/workspace-coordinator.ts new file mode 100644 index 0000000000..684746622a --- /dev/null +++ b/src/remote-control/workspace-coordinator.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + parseRemoteWorkspaceToolCall, + remoteWorkspaceCodexDeveloperInstructions, + remoteWorkspaceCapabilityForTool, + remoteWorkspaceDeveloperInstructions, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolCallParams, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; + +export interface RemoteWorkspaceSessionBinding { + sessionId: string; + threadId: string; + executorDeviceId: string; + executorName: string; + rootId: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; +} + +export interface RemoteWorkspaceTransport { + isOnline(deviceId: string): boolean; + invoke(request: RemoteWorkspaceExecutionRequest): Promise; +} + +export interface AppServerDynamicToolRequest { + method: "item/tool/call"; + id: string | number; + params: unknown; +} + +export interface AppServerDynamicToolResponse { + id: string | number; + result: { + contentItems: Array<{ type: "inputText"; text: string }>; + success: boolean; + }; +} + +function identifier(value: string, label: string): string { + if (value.length < 1 || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function resultText(result: RemoteWorkspaceToolResult): string { + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) { + return JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + } + return encoded; +} + +export function remoteWorkspaceThreadStartParams(options: { + executorName: string; + coordinatorIsolationPath: string; + tools: readonly RemoteWorkspaceToolName[]; + platform?: NodeJS.Platform; + windowsSystemDirectory?: string; + mcp?: { + url: string; + bearerTokenEnvVar: string; + disabledServerNames?: readonly string[]; + disabledHookNames?: readonly string[]; + hubRuntimeReadPaths?: readonly string[]; + }; +}): Record { + if (!isAbsolute(options.coordinatorIsolationPath) || options.coordinatorIsolationPath.includes("\0")) { + throw new Error("remote workspace coordinator isolation path must be absolute"); + } + const platform = options.platform ?? process.platform; + const shellEnvironment = platform === "win32" + ? { + HOME: options.coordinatorIsolationPath, + USERPROFILE: options.coordinatorIsolationPath, + TEMP: options.coordinatorIsolationPath, + TMP: options.coordinatorIsolationPath, + PATH: options.windowsSystemDirectory ?? resolveTrustedWindowsSystemDirectory(), + } + : { + HOME: options.coordinatorIsolationPath, + PATH: platform === "darwin" ? "/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + }; + const config = options.mcp ? { + // A Remote Workspace thread may authenticate/model-call from the Hub, but every + // model-visible action must either be the one OCX MCP server or fail closed. + default_permissions: "ocx-remote-deny-local", + permissions: { + "ocx-remote-deny-local": { + description: "Deny Hub-local command filesystem and network access for Remote Workspace.", + filesystem: { + ":minimal": "read", + ":workspace_roots": { ".": "read" }, + ...Object.fromEntries((options.mcp.hubRuntimeReadPaths ?? []).map(path => [path, "read"])), + }, + network: { enabled: false }, + }, + }, + approval_policy: "never", + allow_login_shell: false, + shell_environment_policy: { + inherit: "none", + ignore_default_excludes: false, + set: shellEnvironment, + }, + web_search: "disabled", + tools: { view_image: false, web_search: false }, + agents: { enabled: false }, + apps: { _default: { enabled: false } }, + features: { + apps: false, + browser_use: false, + computer_use: false, + in_app_browser: false, + memories: false, + multi_agent: false, + plugins: false, + remote_plugin: false, + }, + memories: { use_memories: false, generate_memories: false }, + hooks: Object.fromEntries((options.mcp.disabledHookNames ?? []).map(name => [name, []])), + mcp_servers: { + ...Object.fromEntries((options.mcp.disabledServerNames ?? []) + .filter(name => name !== REMOTE_WORKSPACE_TOOL_NAMESPACE) + .map(name => [name, { enabled: false }])), + [REMOTE_WORKSPACE_TOOL_NAMESPACE]: { + enabled: true, + required: true, + url: options.mcp.url, + bearer_token_env_var: options.mcp.bearerTokenEnvVar, + enabled_tools: [...options.tools], + default_tools_approval_mode: "approve", + startup_timeout_sec: 5, + tool_timeout_sec: 65, + }, + }, + } : undefined; + return { + cwd: options.coordinatorIsolationPath, + runtimeWorkspaceRoots: [options.coordinatorIsolationPath], + approvalPolicy: "never", + ephemeral: false, + serviceName: "opencodex_remote_workspace", + developerInstructions: options.mcp + ? remoteWorkspaceCodexDeveloperInstructions(options.executorName, options.tools) + : remoteWorkspaceDeveloperInstructions(options.executorName, options.tools), + ...(config ? { config } : {}), + }; +} + +export class RemoteWorkspaceCoordinator { + private readonly sessions = new Map(); + + constructor(private readonly transport: RemoteWorkspaceTransport) {} + + register(binding: RemoteWorkspaceSessionBinding): () => void { + const capabilities = [...binding.capabilities]; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (tools.length < 1) throw new Error("remote workspace binding has no usable tools"); + const normalized: RemoteWorkspaceSessionBinding = { + sessionId: identifier(binding.sessionId, "session ID"), + threadId: identifier(binding.threadId, "thread ID"), + executorDeviceId: identifier(binding.executorDeviceId, "executor device ID"), + executorName: identifier(binding.executorName, "executor name"), + rootId: identifier(binding.rootId, "root ID"), + capabilities, + tools, + }; + if (this.sessions.has(normalized.threadId)) throw new Error("remote workspace thread is already bound"); + this.sessions.set(normalized.threadId, normalized); + return () => { + if (this.sessions.get(normalized.threadId)?.sessionId === normalized.sessionId) { + this.sessions.delete(normalized.threadId); + } + }; + } + + async handle(request: AppServerDynamicToolRequest): Promise { + if (request.method !== "item/tool/call") throw new Error("unsupported App Server request"); + let call: RemoteWorkspaceToolCallParams; + try { + call = parseRemoteWorkspaceToolCall(request.params); + } catch (error) { + return this.response(request.id, { ok: false, error: error instanceof Error ? error.message : "invalid remote tool call" }); + } + const binding = this.sessions.get(call.threadId); + if (!binding) return this.response(request.id, { ok: false, error: "remote workspace thread is not bound" }); + if (!binding.tools.includes(call.tool) + || !binding.capabilities.includes(remoteWorkspaceCapabilityForTool(call.tool))) { + return this.response(request.id, { ok: false, error: "remote workspace tool is not supported by this executor" }); + } + if (!this.transport.isOnline(binding.executorDeviceId)) { + return this.response(request.id, { ok: false, error: "remote executor is offline; local fallback is disabled" }); + } + let result: RemoteWorkspaceToolResult; + try { + result = await this.transport.invoke({ + requestId: randomUUID(), + sessionId: binding.sessionId, + executorDeviceId: binding.executorDeviceId, + rootId: binding.rootId, + tool: call.tool, + arguments: call.arguments, + }); + } catch { + result = { ok: false, error: "remote executor transport failed; local fallback is disabled" }; + } + return this.response(request.id, result); + } + + private response(id: string | number, result: RemoteWorkspaceToolResult): AppServerDynamicToolResponse { + return { + id, + result: { + contentItems: [{ type: "inputText", text: resultText(result) }], + success: result.ok, + }, + }; + } +} diff --git a/src/remote-control/workspace-device.ts b/src/remote-control/workspace-device.ts new file mode 100644 index 0000000000..40e42d817b --- /dev/null +++ b/src/remote-control/workspace-device.ts @@ -0,0 +1,585 @@ +import { createPrivateKey, createPublicKey, randomUUID, sign, verify } from "node:crypto"; +import { arch, hostname, platform } from "node:os"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { RemoteWorkspaceExecutorAgentConnection } from "./workspace-agent-connection"; +import { + createPlatformRemoteWorkspaceCommandRunner, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + pinRemoteWorkspaceNativeHelper, + type RemoteWorkspaceNativeHelperDescriptor, +} from "./workspace-command-runner"; +import type { RemoteWorkspaceCommandRunner } from "./workspace-executor"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_DEVICE_STATE_VERSION = 1 as const; +const DEVICE_TOKEN_PATTERN = /^ocxrw_[A-Za-z0-9_-]{43}$/; +const MAX_PAIR_RESPONSE_BYTES = 64 * 1024; +const MAX_DEVICE_STATE_BYTES = 1024 * 1024; +const PAIR_TIMEOUT_MS = 15_000; + +export interface RemoteWorkspaceDeviceRoot { + id: string; + label: string; + path: string; +} + +export interface RemoteWorkspaceDeviceState { + version: typeof REMOTE_WORKSPACE_DEVICE_STATE_VERSION; + hubUrl: string; + agentUrl: string; + deviceId: string; + deviceName: string; + devicePlatform: string; + capabilities: RemoteWorkspaceCapability[]; + deviceToken: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + roots: RemoteWorkspaceDeviceRoot[]; + toolchainRoots: string[]; + nativeHelper?: RemoteWorkspaceNativeHelperDescriptor; +} + +export interface RemoteWorkspaceDeviceStateStore { + load(): RemoteWorkspaceDeviceState | null; + save(state: RemoteWorkspaceDeviceState): void; +} + +export interface PairRemoteWorkspaceDeviceOptions { + hubUrl: string; + pairingCode: string; + name?: string; + roots: Array<{ path: string; label?: string }>; + fetchImpl?: typeof fetch; + store?: RemoteWorkspaceDeviceStateStore; + devicePlatform?: string; + capabilities?: RemoteWorkspaceCapability[]; + toolchainRoots?: string[]; + nativeHelperPath?: string; +} + +export interface RemoteWorkspaceWebSocketLike { + readyState: number; + send(value: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "open" | "close" | "error" | "message", listener: (event: Event | MessageEvent) => void): void; +} + +export type RemoteWorkspaceWebSocketFactory = ( + url: string, + headers: Record, +) => RemoteWorkspaceWebSocketLike; + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function uuid(value: unknown, label: string): string { + const text = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) { + throw new Error(`invalid remote workspace ${label}`); + } + return text; +} + +function publicKey(value: unknown, label: string): string { + const encoded = boundedText(value, label, 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(`invalid remote workspace ${label}`); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error(`remote workspace ${label} must use Ed25519`); + return encoded; +} + +function identity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device identity"); + const raw = value as Record; + const pub = publicKey(raw.publicKey, "device public key"); + const priv = boundedText(raw.privateKey, "device private key", 2048); + const privateKey = createPrivateKey({ key: Buffer.from(priv, "base64url"), type: "pkcs8", format: "der" }); + if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace device identity v1", "utf8"); + if (!verify( + null, + challenge, + createPublicKey({ key: Buffer.from(pub, "base64url"), type: "spki", format: "der" }), + sign(null, challenge, privateKey), + )) throw new Error("remote workspace device identity key pair does not match"); + return { publicKey: pub, privateKey: priv }; +} + +export function normalizeRemoteWorkspaceHubUrl(value: string): string { + const url = new URL(value); + const local = (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.protocol === "http:"; + if (url.protocol !== "https:" && !local) throw new Error("remote workspace hub must use HTTPS"); + if (url.username || url.password || url.search || url.hash) throw new Error("remote workspace hub URL must not contain credentials or fragments"); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); +} + +function agentUrlForHub(hubUrl: string): string { + const url = new URL("/remote-workspace/agent", `${hubUrl}/`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function validateRootInputs(values: Array<{ path: string; label?: string }>): RemoteWorkspaceDeviceRoot[] { + if (values.length < 1 || values.length > 32) throw new Error("remote workspace device needs one to 32 roots"); + const paths = new Set(); + const labels = new Set(); + return values.map(value => { + if (!isAbsolute(value.path) || value.path.includes("\0")) throw new Error("remote workspace root must be an absolute path"); + const metadata = lstatSync(value.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const path = realpathSync(value.path); + const label = boundedText(value.label ?? basename(path), "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (paths.has(path) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + paths.add(path); + labels.add(folded); + return { id: randomUUID(), label, path }; + }); +} + +function parseRoots(value: unknown): RemoteWorkspaceDeviceRoot[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) throw new Error("invalid remote workspace device roots"); + const paths = new Set(); + const ids = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device root"); + const raw = item as Record; + const id = uuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const path = boundedText(raw.path, "root path", 4096); + if (!isAbsolute(path) || ids.has(id) || paths.has(path)) throw new Error("invalid remote workspace device root"); + ids.add(id); + paths.add(path); + return { id, label, path }; + }); +} + +function validateToolchainRoots(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 16) throw new Error("invalid remote workspace toolchain roots"); + const paths = new Set(); + for (const candidate of value) { + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { + throw new Error("remote workspace toolchain root must be an absolute directory"); + } + const metadata = lstatSync(candidate); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must be a real directory"); + } + paths.add(realpathSync(candidate)); + } + return [...paths]; +} + +export function parseRemoteWorkspaceDeviceState(value: unknown): RemoteWorkspaceDeviceState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_DEVICE_STATE_VERSION) throw new Error("unsupported remote workspace device state"); + const hubUrl = normalizeRemoteWorkspaceHubUrl(boundedText(raw.hubUrl, "hub URL", 2048)); + const agentUrl = boundedText(raw.agentUrl, "agent URL", 2048); + if (agentUrl !== agentUrlForHub(hubUrl)) throw new Error("remote workspace agent URL does not match its hub"); + const deviceToken = boundedText(raw.deviceToken, "device token", 128); + if (!DEVICE_TOKEN_PATTERN.test(deviceToken)) throw new Error("invalid remote workspace device token"); + return { + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl, + deviceId: uuid(raw.deviceId, "device ID"), + deviceName: boundedText(raw.deviceName, "device name", 80), + devicePlatform: boundedText(raw.devicePlatform, "device platform", 80), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + deviceToken, + deviceIdentity: identity(raw.deviceIdentity), + hubPublicKey: publicKey(raw.hubPublicKey, "hub public key"), + roots: parseRoots(raw.roots), + toolchainRoots: validateToolchainRoots(raw.toolchainRoots), + ...(raw.nativeHelper === undefined + ? {} + : { nativeHelper: parseRemoteWorkspaceNativeHelperDescriptor(raw.nativeHelper) }), + }; +} + +export class RemoteWorkspaceDeviceFileStore implements RemoteWorkspaceDeviceStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-device.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceDeviceState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_DEVICE_STATE_BYTES) { + throw new Error("remote workspace device state is too large"); + } + return parseRemoteWorkspaceDeviceState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceDeviceState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceDeviceState(state), null, 2)}\n`); + } +} + +async function boundedJson(response: Response): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PAIR_RESPONSE_BYTES) throw new Error("remote workspace hub response is too large"); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + if (reader) { + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PAIR_RESPONSE_BYTES) { + await reader.cancel("remote workspace hub response is too large").catch(() => {}); + throw new Error("remote workspace hub response is too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(body); + try { return text ? JSON.parse(text) : {}; } + catch { throw new Error(`remote workspace hub returned HTTP ${response.status}`); } +} + +export async function pairRemoteWorkspaceDevice(options: PairRemoteWorkspaceDeviceOptions): Promise { + const hubUrl = normalizeRemoteWorkspaceHubUrl(options.hubUrl); + const roots = validateRootInputs(options.roots); + const deviceName = boundedText(options.name ?? hostname(), "device name", 80); + const devicePlatform = boundedText(options.devicePlatform ?? `${platform()}-${arch()}`, "device platform", 80); + const toolchainRoots = validateToolchainRoots(options.toolchainRoots); + const nativeHelper = options.nativeHelperPath + ? pinRemoteWorkspaceNativeHelper(options.nativeHelperPath) + : discoverRemoteWorkspaceNativeHelper(); + const commandRunner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { toolchainRoots, writableRoots: roots.map(root => root.path) }, + ...(nativeHelper ? { native: { + helper: nativeHelper, + toolchainRoots, + writableRoots: roots.map(root => root.path), + } } : {}), + }); + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, options.capabilities); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const response = await (options.fetchImpl ?? fetch)(new URL("/remote-workspace/pair", `${hubUrl}/`), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(PAIR_TIMEOUT_MS), + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + code: options.pairingCode, + name: deviceName, + platform: devicePlatform, + publicKey: deviceIdentity.publicKey, + capabilities, + roots: roots.map(root => ({ id: root.id, label: root.label })), + }), + }); + const body = await boundedJson(response); + if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) { + const error = body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `remote workspace pairing failed (${response.status})`; + throw new Error(error); + } + const raw = body as Record; + const device = raw.device && typeof raw.device === "object" && !Array.isArray(raw.device) + ? raw.device as Record + : null; + if (!device) throw new Error("remote workspace hub returned an invalid device"); + const state = parseRemoteWorkspaceDeviceState({ + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl: agentUrlForHub(hubUrl), + deviceId: device.id, + deviceName, + devicePlatform, + capabilities, + deviceToken: raw.deviceToken, + deviceIdentity, + hubPublicKey: raw.hubPublicKey, + roots, + toolchainRoots, + ...(nativeHelper ? { nativeHelper } : {}), + }); + (options.store ?? new RemoteWorkspaceDeviceFileStore()).save(state); + return state; +} + +function defaultWebSocketFactory(url: string, headers: Record): RemoteWorkspaceWebSocketLike { + return new WebSocket(url, { headers } as unknown as string[]) as unknown as RemoteWorkspaceWebSocketLike; +} + +async function messageBytes(event: MessageEvent): Promise { + if (typeof event.data === "string") return event.data; + if (event.data instanceof ArrayBuffer) return new Uint8Array(event.data); + if (ArrayBuffer.isView(event.data)) return new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (event.data instanceof Blob) return new Uint8Array(await event.data.arrayBuffer()); + throw new Error("remote workspace agent received an unsupported frame"); +} + +export interface RemoteWorkspaceAgentHandle { + connected: Promise; + closed: Promise; + stop(): void; +} + +export interface RemoteWorkspaceAgentRunStatus { + state: "connecting" | "online" | "reconnecting" | "stopped"; + attempt: number; + message?: string; +} + +/** Never advertise more authority than both local support and the pairing-time grant allow. */ +export function remoteWorkspaceCapabilitiesForCommandRunner( + commandRunner: RemoteWorkspaceCommandRunner | undefined, + approved?: readonly RemoteWorkspaceCapability[], +): RemoteWorkspaceCapability[] { + const available = parseRemoteWorkspaceCapabilities([ + "workspace.read", + "workspace.write", + ...(commandRunner ? ["workspace.exec" as const] : []), + ]); + const requested = parseRemoteWorkspaceCapabilities(approved ?? available); + const allowed = new Set(available); + return parseRemoteWorkspaceCapabilities(requested.filter(capability => allowed.has(capability))); +} + +export function connectRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; +}): RemoteWorkspaceAgentHandle { + const state = parseRemoteWorkspaceDeviceState(options.state); + const commandRunner = options.commandRunner === undefined + ? createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }) + : options.commandRunner ?? undefined; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, state.capabilities); + const executor = new RemoteWorkspaceExecutor({ + deviceId: state.deviceId, + roots: state.roots.map(root => ({ id: root.id, path: root.path })), + commandRunner, + }); + const socket = (options.webSocketFactory ?? defaultWebSocketFactory)(state.agentUrl, { + authorization: `Bearer ${state.deviceToken}`, + }); + let agent: RemoteWorkspaceExecutorAgentConnection | null = null; + let opened = false; + let presenceAccepted = false; + let stopped = false; + let settleConnected!: () => void; + let rejectConnected!: (error: Error) => void; + let settleClosed!: () => void; + const connected = new Promise((resolve, reject) => { + settleConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise(resolve => { settleClosed = resolve; }); + let queue = Promise.resolve(); + let heartbeat: ReturnType | null = null; + let presenceTimer: ReturnType | null = null; + const acceptPresence = () => { + if (stopped) return; + if (presenceAccepted) return; + presenceAccepted = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + settleConnected(); + }; + + socket.addEventListener("open", () => { + if (stopped) { + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* already closed */ } + return; + } + opened = true; + presenceTimer = setTimeout(() => { + rejectConnected(new Error("remote workspace Hub did not acknowledge executor capabilities")); + socket.close(1008, "remote workspace presence timed out"); + }, 10_000); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId: state.deviceId, + deviceIdentity: state.deviceIdentity, + hubPublicKey: state.hubPublicKey, + executor, + capabilities, + onPresenceAccepted: acceptPresence, + socket: { + send: value => socket.send(value), + close: (code, reason) => socket.close(code, reason), + }, + }); + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities, + })); + heartbeat = setInterval(() => { + if (socket.readyState !== 1) return; + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "heartbeat", + nonce: randomUUID(), + })); + }, 20_000); + }); + socket.addEventListener("message", event => { + if (stopped || !(event instanceof MessageEvent) || !agent) return; + queue = queue.then(async () => agent?.receive(await messageBytes(event))).catch(() => { + socket.close(1008, "remote workspace protocol error"); + }); + }); + socket.addEventListener("error", () => { + if (!stopped && !presenceAccepted) rejectConnected(new Error("remote workspace agent connection failed")); + }); + socket.addEventListener("close", () => { + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error( + opened + ? "remote workspace agent connection closed before presence acknowledgement" + : "remote workspace agent connection closed before opening", + )); + settleClosed(); + }); + return { + connected, + closed, + stop() { + if (stopped) return; + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error("remote workspace agent stopped")); + settleClosed(); + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* CONNECTING sockets differ by runtime */ } + }, + }; +} + +function waitForReconnect(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(finish, delayMs); + function finish() { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function runRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + signal: AbortSignal; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; + minReconnectMs?: number; + maxReconnectMs?: number; + random?: () => number; +}): Promise { + const state = parseRemoteWorkspaceDeviceState(options.state); + const minimum = options.minReconnectMs ?? 500; + const maximum = options.maxReconnectMs ?? 15_000; + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum < 10 || maximum < minimum) { + throw new Error("invalid remote workspace reconnect policy"); + } + let attempt = 0; + let delayMs = minimum; + while (!options.signal.aborted) { + attempt += 1; + options.onStatus?.({ state: "connecting", attempt }); + const handle = connectRemoteWorkspaceAgent({ + state, + ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}), + ...(options.commandRunner !== undefined ? { commandRunner: options.commandRunner } : {}), + }); + const stop = () => handle.stop(); + options.signal.addEventListener("abort", stop, { once: true }); + try { + await handle.connected; + delayMs = minimum; + options.onStatus?.({ state: "online", attempt }); + await handle.closed; + } catch (error) { + handle.stop(); + if (!options.signal.aborted) { + options.onStatus?.({ + state: "reconnecting", + attempt, + message: error instanceof Error ? error.message : "remote workspace connection failed", + }); + } + } finally { + options.signal.removeEventListener("abort", stop); + } + if (options.signal.aborted) break; + options.onStatus?.({ state: "reconnecting", attempt }); + const random = Math.min(1, Math.max(0, (options.random ?? Math.random)())); + const jitteredDelay = Math.max(10, Math.round(delayMs * (0.8 + random * 0.4))); + await waitForReconnect(jitteredDelay, options.signal); + delayMs = Math.min(maximum, delayMs * 2); + } + options.onStatus?.({ state: "stopped", attempt }); +} diff --git a/src/remote-control/workspace-executable.ts b/src/remote-control/workspace-executable.ts new file mode 100644 index 0000000000..db6153303c --- /dev/null +++ b/src/remote-control/workspace-executable.ts @@ -0,0 +1,43 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +function executableCandidate(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Resolve only durable PATH entries; an empty/current-directory entry is never trusted. */ +export function findExecutableOnPath(name: string, options: { + path?: string; + pathExt?: string; + platform?: NodeJS.Platform; + /** Pure cross-platform test seam; production checks the real filesystem. */ + probe?: (candidate: string) => boolean; +} = {}): string | null { + const path = options.path ?? process.env.PATH; + const platform = options.platform ?? process.platform; + if (!path) return null; + const paths = platform === "win32" ? win32 : posix; + const spawnableWindowsExtensions = new Set([".com", ".exe", ".bat", ".cmd"]); + const suffixes = platform === "win32" + ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map(value => value.trim()) + .filter(value => spawnableWindowsExtensions.has(value.toLowerCase())) + : [""]; + if (platform === "win32" && win32.extname(name)) suffixes.unshift(""); + const probe = options.probe ?? (candidate => executableCandidate(candidate, platform)); + for (const directory of path.split(paths.delimiter)) { + if (!directory) continue; + for (const suffix of suffixes) { + const candidate = paths.join(directory, `${name}${suffix.toLowerCase()}`); + if (probe(candidate)) return candidate; + } + } + return null; +} diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts new file mode 100644 index 0000000000..3312e8348f --- /dev/null +++ b/src/remote-control/workspace-executor.ts @@ -0,0 +1,396 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + opendirSync, + readSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export interface RemoteWorkspaceRoot { + id: string; + path: string; +} + +export interface RemoteWorkspaceExecutionRequest { + requestId: string; + sessionId: string; + executorDeviceId: string; + rootId: string; + tool: RemoteWorkspaceToolName; + arguments: unknown; +} + +export interface RemoteWorkspaceExecutorOptions { + deviceId: string; + roots: readonly RemoteWorkspaceRoot[]; + maxOutputBytes?: number; + platform?: NodeJS.Platform; + /** Production must provide an OS-sandboxed runner. Omission disables command execution. */ + commandRunner?: RemoteWorkspaceCommandRunner; +} + +export interface RemoteWorkspaceCommandRequest { + command: string[]; + root: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; +} + +export interface RemoteWorkspaceCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RemoteWorkspaceCommandRunner { + run(request: RemoteWorkspaceCommandRequest): Promise; +} + +interface ApprovedRoot { + id: string; + path: string; + dev: number; + ino: number; + birthtimeMs: number; +} + +function objectArguments(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace arguments must be an object"); + } + return value as Record; +} + +function noExtraKeys(value: Record, allowed: readonly string[]): void { + const set = new Set(allowed); + if (Object.keys(value).some(key => !set.has(key))) throw new Error("unknown remote workspace argument"); +} + +const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i; + +export function validateRemoteWorkspaceRelativePath( + value: unknown, + fallback?: string, + platform: NodeJS.Platform = process.platform, +): string { + const path = value === undefined ? fallback : value; + if (typeof path !== "string" || path.length < 1 || path.length > 4096 || path.includes("\0")) { + throw new Error("invalid remote workspace path"); + } + const paths = platform === "win32" ? win32 : posix; + if (paths.isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) { + throw new Error("remote workspace path must be relative"); + } + if (platform === "win32") { + for (const segment of path.split(/[\\/]/)) { + if (!segment || segment === "." || segment === "..") continue; + if (/[\x01-\x1f<>:"|?*]/.test(segment) || /[ .]$/.test(segment) || WINDOWS_RESERVED_BASENAME.test(segment)) { + throw new Error("remote workspace path is not a safe Windows file path"); + } + } + } + return path; +} + +function inside(root: string, candidate: string): boolean { + const fromRoot = relative(root, candidate); + return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)); +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function assertNoSymlinkComponents(root: string, candidate: string, includeLeaf: boolean): void { + const rel = relative(root, candidate); + const parts = rel === "" ? [] : rel.split(sep); + const limit = includeLeaf ? parts.length : Math.max(0, parts.length - 1); + let current = root; + for (let index = 0; index < limit; index += 1) { + current = resolve(current, parts[index]!); + if (lstatSync(current).isSymbolicLink()) throw new Error("remote workspace symlink traversal is not allowed"); + } +} + +function resolveExisting(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, ".", platform)); + if (!inside(root, candidate)) throw new Error("remote workspace path escapes the approved root"); + assertNoSymlinkComponents(root, candidate, true); + const canonical = realpathSync(candidate); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + return canonical; +} + +function resolveWritable(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, undefined, platform)); + if (!inside(root, candidate) || candidate === root) throw new Error("remote workspace path escapes the approved root"); + const parent = dirname(candidate); + assertNoSymlinkComponents(root, parent, true); + const canonicalParent = realpathSync(parent); + if (!inside(root, canonicalParent)) throw new Error("remote workspace parent escapes the approved root"); + try { + assertNoSymlinkComponents(root, candidate, true); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + return resolve(canonicalParent, basename(candidate)); +} + +function sha256(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number { + const selected = value === undefined ? fallback : value; + if (typeof selected !== "number" || !Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new Error("invalid remote workspace numeric argument"); + } + return selected; +} + +function decodeUtf8(value: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(value); +} + +function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { + const opened = fstatSync(descriptor); + const linked = lstatSync(target); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + throw new Error("remote workspace hard-linked files are not allowed"); + } + if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() + || opened.dev !== linked.dev || opened.ino !== linked.ino + || opened.birthtimeMs !== linked.birthtimeMs) { + throw new Error("remote workspace file identity changed during access"); + } + const canonical = realpathSync(target); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); + return opened; +} + +function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const descriptor = openSync(target, constants.O_RDONLY | noFollow); + try { + const metadata = assertOpenedRegularFile(root, target, descriptor, maximum); + const body = Buffer.alloc(metadata.size); + let offset = 0; + while (offset < body.byteLength) { + const read = readSync(descriptor, body, offset, body.byteLength - offset, null); + if (read === 0) break; + offset += read; + } + assertOpenedRegularFile(root, target, descriptor, maximum); + return { body: offset === body.byteLength ? body : body.subarray(0, offset), mode: metadata.mode & 0o777 }; + } finally { + closeSync(descriptor); + } +} + +function assertStableWritableParent(root: string, target: string): void { + const parent = dirname(target); + assertNoSymlinkComponents(root, parent, true); + const canonical = realpathSync(parent); + if (!inside(root, canonical) || relative(parent, canonical) !== "") { + throw new Error("remote workspace write parent changed during access"); + } +} + +function assertApprovedRootIdentity(root: ApprovedRoot): void { + const linked = lstatSync(root.path); + const canonical = realpathSync(root.path); + if (!linked.isDirectory() || linked.isSymbolicLink() + || linked.dev !== root.dev || linked.ino !== root.ino + || linked.birthtimeMs !== root.birthtimeMs + || relative(root.path, canonical) !== "") { + throw new Error("remote workspace approved root identity changed; pair the folder again"); + } +} + +function assertWritePrecondition(root: string, target: string, expectedSha256: string | null): number { + try { + const current = readBoundedRegularFile(root, target, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES); + if (expectedSha256 === null || sha256(current.body) !== expectedSha256) { + throw new Error("remote workspace file changed before write"); + } + return current.mode; + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + if (expectedSha256 !== null) throw new Error("remote workspace file is missing"); + return 0o600; + } +} + +export class RemoteWorkspaceExecutor { + private readonly roots = new Map(); + private readonly maxOutputBytes: number; + private operationTail: Promise = Promise.resolve(); + + constructor(private readonly options: RemoteWorkspaceExecutorOptions) { + if (!options.deviceId || options.deviceId.length > 256) throw new Error("invalid remote workspace executor device ID"); + this.maxOutputBytes = options.maxOutputBytes ?? REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES; + if (!Number.isSafeInteger(this.maxOutputBytes) || this.maxOutputBytes < 1024) { + throw new Error("invalid remote workspace output limit"); + } + for (const root of options.roots) { + if (!root.id || root.id.length > 128 || this.roots.has(root.id)) throw new Error("invalid remote workspace root ID"); + const metadata = lstatSync(root.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const canonical = realpathSync(root.path); + const identity = lstatSync(canonical); + this.roots.set(root.id, { + id: root.id, + path: canonical, + dev: identity.dev, + ino: identity.ino, + birthtimeMs: identity.birthtimeMs, + }); + } + if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); + } + + hasApprovedRoot(rootId: string): boolean { + return this.roots.has(rootId); + } + + async invoke(request: RemoteWorkspaceExecutionRequest, signal?: AbortSignal): Promise { + if (request.executorDeviceId !== this.options.deviceId) { + return { ok: false, error: "remote workspace executor identity mismatch" }; + } + const root = this.roots.get(request.rootId); + if (!root) return { ok: false, error: "remote workspace root is not approved" }; + if (!request.requestId || !request.sessionId) return { ok: false, error: "invalid remote workspace request identity" }; + const previous = this.operationTail; + let release!: () => void; + this.operationTail = new Promise(resolvePromise => { release = resolvePromise; }); + await previous; + try { + if (signal?.aborted) throw new Error("remote workspace operation was cancelled"); + assertApprovedRootIdentity(root); + switch (request.tool) { + case "list_directory": return { ok: true, value: this.listDirectory(root, request.arguments) }; + case "read_file": return { ok: true, value: this.readFile(root, request.arguments) }; + case "write_file": return { ok: true, value: this.writeFile(root, request.arguments) }; + case "exec": return { ok: true, value: await this.exec(root, request.arguments, signal) }; + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "remote workspace operation failed" }; + } finally { + release(); + } + } + + private listDirectory(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path"]); + const target = resolveExisting(root.path, args.path ?? ".", this.options.platform); + if (!statSync(target).isDirectory()) throw new Error("remote workspace list target is not a directory"); + const directory = opendirSync(target); + const entries: Array<{ name: string; type: "directory" | "file" | "symlink" | "other" }> = []; + try { + while (true) { + const entry = directory.readSync(); + if (!entry) break; + if (entries.length >= 4096) throw new Error("remote workspace directory has too many entries"); + entries.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other", + }); + } + } finally { + directory.closeSync(); + } + return { + path: relative(root.path, target) || ".", + entries, + }; + } + + private readFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "maxBytes"]); + const target = resolveExisting(root.path, args.path, this.options.platform); + const maxBytes = boundedInteger(args.maxBytes, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, 1, this.maxOutputBytes); + const { body } = readBoundedRegularFile(root.path, target, maxBytes); + return { path: relative(root.path, target), content: decodeUtf8(body), sha256: sha256(body), bytes: body.byteLength }; + } + + private writeFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "content", "expectedSha256"]); + if (typeof args.content !== "string") throw new Error("remote workspace file content must be text"); + const body = Buffer.from(args.content, "utf8"); + if (body.byteLength > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) throw new Error("remote workspace file exceeds the write limit"); + const expectedSha256 = args.expectedSha256; + if (expectedSha256 !== null && (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256))) { + throw new Error("invalid remote workspace expected file hash"); + } + const target = resolveWritable(root.path, args.path, this.options.platform); + const mode = assertWritePrecondition(root.path, target, expectedSha256); + const temporary = resolve(dirname(target), `.${randomUUID()}.ocx-remote-write`); + try { + writeFileSync(temporary, body, { flag: "wx", mode }); + assertStableWritableParent(root.path, target); + assertWritePrecondition(root.path, target, expectedSha256); + renameAtomicFile(temporary, target, undefined, "remote-workspace"); + } finally { + try { unlinkSync(temporary); } catch { /* committed or already absent */ } + } + return { path: relative(root.path, target), sha256: sha256(body), bytes: body.byteLength }; + } + + private async exec(root: ApprovedRoot, input: unknown, signal?: AbortSignal): Promise { + if (!this.options.commandRunner) { + throw new Error("remote workspace command runner is disabled until an OS sandbox is configured"); + } + const args = objectArguments(input); + noExtraKeys(args, ["command", "cwd", "timeoutMs"]); + if (!Array.isArray(args.command) || args.command.length < 1 || args.command.length > 64) { + throw new Error("invalid remote workspace command vector"); + } + const command: string[] = []; + for (const value of args.command) { + if (typeof value !== "string" || value.length < 1 || value.length > 4096 || value.includes("\0")) { + throw new Error("invalid remote workspace command vector"); + } + command.push(value); + } + if (command.reduce((total, value) => total + value.length, 0) > 16 * 1024) { + throw new Error("remote workspace command vector is too large"); + } + const cwd = resolveExisting(root.path, args.cwd ?? ".", this.options.platform); + if (!statSync(cwd).isDirectory()) throw new Error("remote workspace command cwd is not a directory"); + const timeoutMs = boundedInteger(args.timeoutMs, 30_000, 1, 60_000); + const result = await this.options.commandRunner.run({ + command, + root: root.path, + cwd, + timeoutMs, + maxOutputBytes: this.maxOutputBytes, + signal, + }); + const outputBytes = Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8"); + if (outputBytes > this.maxOutputBytes) { + throw new Error("remote workspace command runner exceeded its output contract"); + } + return { cwd: relative(root.path, cwd) || ".", ...result }; + } +} diff --git a/src/remote-control/workspace-hub.ts b/src/remote-control/workspace-hub.ts new file mode 100644 index 0000000000..b1c08e4c3b --- /dev/null +++ b/src/remote-control/workspace-hub.ts @@ -0,0 +1,519 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + randomUUID, + sign, + timingSafeEqual, + verify, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import type { RemoteWorkspaceHubAgentConnection } from "./workspace-agent-connection"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_HUB_STATE_VERSION = 1 as const; +export const REMOTE_WORKSPACE_MAX_DEVICES = 32; +export const REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE = 32; +const PAIRING_LIFETIME_MS = 10 * 60_000; +const MAX_PAIRING_GRANTS = 16; +const MAX_HUB_STATE_BYTES = 1024 * 1024; +const TOKEN_PREFIX = "ocxrw_"; +const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000; +const PAIRING_SOURCE_FAILURE_LIMIT = 10; +const PAIRING_SOURCE_LIMIT = 1_024; + +export interface RemoteWorkspaceRootAdvertisement { + id: string; + label: string; +} + +export interface RemoteWorkspaceStoredDevice { + id: string; + name: string; + platform: string; + publicKey: string; + tokenHash: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspaceHubState { + version: typeof REMOTE_WORKSPACE_HUB_STATE_VERSION; + identity: RemoteControlIdentityKeyPair; + devices: RemoteWorkspaceStoredDevice[]; +} + +export interface RemoteWorkspaceHubStateStore { + load(): RemoteWorkspaceHubState | null; + save(state: RemoteWorkspaceHubState): void; +} + +export interface RemoteWorkspacePublicDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspacePairingGrant { + code: string; + expiresAt: string; +} + +export interface RemoteWorkspacePairDeviceInput { + code: string; + name: string; + platform: string; + publicKey: string; + capabilities?: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; +} + +export interface RemoteWorkspacePairDeviceResult { + device: RemoteWorkspacePublicDevice; + deviceToken: string; + hubPublicKey: string; +} + +interface PendingPairingGrant { + hash: Buffer; + expiresAt: number; +} + +interface PairingSourceFailureRecord { + failures: number; + windowStartedAt: number; +} + +export class RemoteWorkspacePairingRateLimitError extends Error { + constructor( + readonly retryAfterSeconds: number, + readonly reason: "source" | "capacity", + ) { + super("remote workspace pairing rate limit exceeded"); + this.name = "RemoteWorkspacePairingRateLimitError"; + } +} + +function sha256(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function encodeHash(value: Buffer): string { + return value.toString("base64url"); +} + +function parseHash(value: unknown): Buffer { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error("invalid remote workspace token hash"); + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== 32) throw new Error("invalid remote workspace token hash"); + return decoded; +} + +function normalizeCode(value: string): string { + return value.replace(/[\s-]/g, "").toUpperCase(); +} + +function newPairingCode(): string { + const bytes = randomBytes(12); + let code = ""; + for (let index = 0; index < bytes.length; index += 1) { + code += PAIRING_ALPHABET[bytes[index]! % PAIRING_ALPHABET.length]; + } + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`; +} + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function objectRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid remote workspace device metadata"); + } + return value as Record; +} + +function exactPairingFields(value: Record): void { + const required = ["code", "name", "platform", "publicKey", "roots"] as const; + const allowed = new Set([...required, "capabilities"]); + if (required.some(key => !Object.hasOwn(value, key)) + || Object.keys(value).some(key => !allowed.has(key))) { + throw new Error("invalid remote workspace device metadata"); + } +} + +function validUuid(value: unknown, label: string): string { + const normalized = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function validatePublicKey(value: unknown): string { + const encoded = boundedText(value, "device public key", 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error("invalid remote workspace device public key"); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + return encoded; +} + +function validateIdentity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub identity"); + const raw = value as Record; + const publicKey = validatePublicKey(raw.publicKey); + const privateKey = boundedText(raw.privateKey, "hub private key", 2048); + const privateDer = Buffer.from(privateKey, "base64url"); + const parsed = createPrivateKey({ key: privateDer, type: "pkcs8", format: "der" }); + if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote workspace hub key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace hub identity v1", "utf8"); + const signature = sign(null, challenge, parsed); + const verifier = createPublicKey({ key: Buffer.from(publicKey, "base64url"), type: "spki", format: "der" }); + if (!verify(null, challenge, verifier, signature)) { + throw new Error("remote workspace hub identity key pair does not match"); + } + return { publicKey, privateKey }; +} + +function validateRoots(value: unknown): RemoteWorkspaceRootAdvertisement[] { + if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE) { + throw new Error("remote workspace device needs one to 32 roots"); + } + const ids = new Set(); + const labels = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace root"); + const raw = item as Record; + const id = validUuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (ids.has(id) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + ids.add(id); + labels.add(folded); + return { id, label }; + }); +} + +function validateDate(value: unknown, nullable = false): string | null { + if (nullable && value === null) return null; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("invalid remote workspace timestamp"); + return value; +} + +export function parseRemoteWorkspaceHubState(value: unknown): RemoteWorkspaceHubState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_HUB_STATE_VERSION || !Array.isArray(raw.devices)) { + throw new Error("unsupported remote workspace hub state"); + } + if (raw.devices.length > REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit exceeded"); + const ids = new Set(); + const names = new Set(); + const devices = raw.devices.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device state"); + const device = item as Record; + const id = validUuid(device.id, "device ID"); + const name = boundedText(device.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (ids.has(id) || names.has(folded)) throw new Error("duplicate remote workspace device identity"); + ids.add(id); + names.add(folded); + if (typeof device.tokenHash !== "string") throw new Error("invalid remote workspace token hash"); + parseHash(device.tokenHash); + const tokenHash = device.tokenHash; + return { + id, + name, + platform: boundedText(device.platform, "device platform", 80), + publicKey: validatePublicKey(device.publicKey), + tokenHash, + capabilities: parseRemoteWorkspaceCapabilities(device.capabilities), + roots: validateRoots(device.roots), + createdAt: validateDate(device.createdAt)!, + lastSeenAt: validateDate(device.lastSeenAt, true), + }; + }); + return { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: validateIdentity(raw.identity), + devices, + }; +} + +export class RemoteWorkspaceHubFileStore implements RemoteWorkspaceHubStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-hub.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceHubState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_HUB_STATE_BYTES) { + throw new Error("remote workspace hub state is too large"); + } + return parseRemoteWorkspaceHubState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceHubState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceHubState(state), null, 2)}\n`); + } +} + +export class RemoteWorkspaceHub { + private state: RemoteWorkspaceHubState; + private readonly grants = new Map(); + private readonly pairingSourceFailures = new Map(); + private readonly connections = new Map(); + + constructor( + private readonly store: RemoteWorkspaceHubStateStore, + private readonly now: () => number = Date.now, + ) { + const loaded = store.load(); + this.state = loaded ?? { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: generateRemoteControlIdentityKeyPair(), + devices: [], + }; + if (loaded === null) this.store.save(this.state); + } + + identity(): RemoteControlIdentityKeyPair { + return { ...this.state.identity }; + } + + createPairingGrant(): RemoteWorkspacePairingGrant { + this.pruneGrants(); + if (this.grants.size >= MAX_PAIRING_GRANTS) throw new Error("remote workspace pairing capacity reached"); + let code: string; + let digest: Buffer; + do { + code = newPairingCode(); + digest = sha256(normalizeCode(code)); + } while (this.grants.has(encodeHash(digest))); + const expiresAt = this.now() + PAIRING_LIFETIME_MS; + this.grants.set(encodeHash(digest), { hash: digest, expiresAt }); + return { code, expiresAt: new Date(expiresAt).toISOString() }; + } + + private pairingSourceKey(source: string): string { + return encodeHash(sha256(`remote-workspace-pairing-source\0${source}`)); + } + + private prunePairingSourceFailures(now: number): void { + // Records never extend their original fixed window, so insertion order is expiry order. Stop + // at the first live entry instead of making every unauthenticated request scan the full cap. + for (const [key, record] of this.pairingSourceFailures) { + if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now) break; + this.pairingSourceFailures.delete(key); + } + } + + private pairingSourceRecord(source: string, now: number): [string, PairingSourceFailureRecord | undefined] { + this.prunePairingSourceFailures(now); + const key = this.pairingSourceKey(source); + return [key, this.pairingSourceFailures.get(key)]; + } + + private admitPairingSource(source: string, now: number): string { + const [key, record] = this.pairingSourceRecord(source, now); + if (record && record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + return key; + } + + assertPairingSourceAllowed(source = "anonymous"): void { + this.admitPairingSource(source, this.now()); + } + + private recordPairingSourceFailure(key: string, now: number): void { + let record = this.pairingSourceFailures.get(key); + if (!record) { + if (this.pairingSourceFailures.size >= PAIRING_SOURCE_LIMIT) { + throw new RemoteWorkspacePairingRateLimitError(1, "capacity"); + } + record = { failures: 0, windowStartedAt: now }; + this.pairingSourceFailures.set(key, record); + } + record.failures += 1; + if (record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + } + + pairDevice(input: unknown, source = "anonymous"): RemoteWorkspacePairDeviceResult { + this.pruneGrants(); + const nowMs = this.now(); + const sourceKey = this.admitPairingSource(source, nowMs); + const raw = objectRecord(input); + const normalizedCode = normalizeCode(typeof raw.code === "string" ? raw.code : ""); + if (normalizedCode.length !== 12 || ![...normalizedCode].every(character => PAIRING_ALPHABET.includes(character))) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + const digest = sha256(normalizedCode); + const key = encodeHash(digest); + const grant = this.grants.get(key); + if (!grant || grant.expiresAt <= nowMs || !timingSafeEqual(grant.hash, digest)) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + this.pairingSourceFailures.delete(sourceKey); + // A valid grant is one-shot even when the submitted device metadata is rejected. Keeping it + // alive after a conflict would let the same copied secret authorize repeated enrollment tries. + this.grants.delete(key); + exactPairingFields(raw); + if (this.state.devices.length >= REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit reached"); + const name = boundedText(raw.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (this.state.devices.some(device => device.name.toLocaleLowerCase("en-US") === folded)) { + throw new Error("remote workspace device name is already in use"); + } + const now = new Date(nowMs).toISOString(); + const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; + const device: RemoteWorkspaceStoredDevice = { + id: randomUUID(), + name, + platform: boundedText(raw.platform, "device platform", 80), + publicKey: validatePublicKey(raw.publicKey), + tokenHash: encodeHash(sha256(token)), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + roots: validateRoots(raw.roots), + createdAt: now, + lastSeenAt: null, + }; + this.state = { ...this.state, devices: [...this.state.devices, device] }; + this.store.save(this.state); + return { + device: this.publicDevice(device), + deviceToken: token, + hubPublicKey: this.state.identity.publicKey, + }; + } + + authenticateDeviceToken(token: string): RemoteWorkspaceStoredDevice | null { + if (!token.startsWith(TOKEN_PREFIX) || token.length !== TOKEN_PREFIX.length + 43) return null; + const digest = sha256(token); + for (const device of this.state.devices) { + const stored = parseHash(device.tokenHash); + if (timingSafeEqual(stored, digest)) { + return { ...device, capabilities: [...device.capabilities], roots: device.roots.map(root => ({ ...root })) }; + } + } + return null; + } + + attachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + const index = this.state.devices.findIndex(device => device.id === deviceId); + if (index < 0) throw new Error("unknown remote workspace device"); + const previous = this.connections.get(deviceId); + if (previous && previous !== connection) previous.close("remote workspace executor reconnected"); + this.connections.set(deviceId, connection); + const seen = new Date(this.now()).toISOString(); + this.state = { + ...this.state, + devices: this.state.devices.map((device, deviceIndex) => ( + deviceIndex === index ? { ...device, lastSeenAt: seen } : device + )), + }; + this.store.save(this.state); + } + + updateDeviceCapabilities(deviceId: string, capabilities: readonly RemoteWorkspaceCapability[]): void { + const device = this.state.devices.find(candidate => candidate.id === deviceId); + if (!device) throw new Error("unknown remote workspace device"); + const normalized = parseRemoteWorkspaceCapabilities(capabilities); + if (normalized.some(capability => !device.capabilities.includes(capability))) { + throw new Error("remote workspace presence exceeds enrollment grant"); + } + // Connection availability is transient; the persisted enrollment grant is unchanged. + } + + detachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + if (this.connections.get(deviceId) !== connection) return; + this.connections.delete(deviceId); + connection.close(); + } + + connection(deviceId: string): RemoteWorkspaceHubAgentConnection | null { + const connection = this.connections.get(deviceId); + return connection?.isOnline() ? connection : null; + } + + listDevices(): RemoteWorkspacePublicDevice[] { + return this.state.devices.map(device => this.publicDevice(device)); + } + + revokeDevice(deviceId: string): boolean { + const before = this.state.devices.length; + this.state = { ...this.state, devices: this.state.devices.filter(device => device.id !== deviceId) }; + if (this.state.devices.length === before) return false; + const connection = this.connections.get(deviceId); + this.connections.delete(deviceId); + connection?.close("remote workspace device was revoked"); + this.store.save(this.state); + return true; + } + + closeAllConnections(reason = "remote workspace hub stopped"): void { + const connections = [...this.connections.values()]; + this.connections.clear(); + for (const connection of connections) connection.close(reason); + } + + private publicDevice(device: RemoteWorkspaceStoredDevice): RemoteWorkspacePublicDevice { + return { + id: device.id, + name: device.name, + platform: device.platform, + capabilities: device.capabilities.filter(capability => { + const connection = this.connections.get(device.id); + return !connection || connection.capabilities().includes(capability); + }), + roots: device.roots.map(root => ({ ...root })), + online: this.connections.get(device.id)?.isOnline() ?? false, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + }; + } + + private pruneGrants(): void { + const now = this.now(); + for (const [key, grant] of this.grants) { + if (grant.expiresAt <= now) this.grants.delete(key); + } + } +} diff --git a/src/remote-control/workspace-pi-runtime.ts b/src/remote-control/workspace-pi-runtime.ts new file mode 100644 index 0000000000..a5987a5fe1 --- /dev/null +++ b/src/remote-control/workspace-pi-runtime.ts @@ -0,0 +1,382 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { REMOTE_WORKSPACE_DYNAMIC_TOOLS } from "./workspace-tools"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; + +interface PendingResponse { + resolve(value: Record): void; + reject(error: Error): void; + timer: ReturnType; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function messageText(value: unknown): string | null { + const message = record(value); + if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +function remotePiInstructions(deviceName: string, tools: readonly string[]): string { + const name = deviceName.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) || "remote executor"; + const remoteTools = tools.map(tool => `remote_${tool}`).join(", "); + return [ + `You operate only on the OpenCodex remote executor named ${JSON.stringify(name)}.`, + `Use only these tools for filesystem and command work: ${remoteTools}.`, + "The Hub working directory is an empty isolation boundary, not the user's project.", + "If a remote tool fails or the executor is offline, stop and report it. Never substitute local operations.", + ].join(" "); +} + +function extensionSource(tools: readonly string[]): string { + const allowed = new Set(tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => allowed.has(tool.name)).map(tool => ({ + remoteName: `remote_${tool.name}`, + tool: tool.name, + description: tool.description, + parameters: tool.inputSchema, + })); + return `const definitions = ${JSON.stringify(definitions)}; +const endpoint = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_URL; +const token = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN; + +export default function registerRemoteWorkspace(pi) { + if (!endpoint || !token) throw new Error("Remote Workspace bridge is unavailable"); + for (const definition of definitions) { + pi.registerTool({ + name: definition.remoteName, + label: definition.remoteName, + description: definition.description, + parameters: definition.parameters, + async execute(_toolCallId, parameters, signal) { + const response = await fetch(endpoint + "/invoke", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer " + token }, + body: JSON.stringify({ tool: definition.tool, arguments: parameters }), + signal, + }); + const result = await response.json(); + if (!response.ok || !result || result.success !== true) { + throw new Error(result && typeof result.text === "string" ? result.text : "Remote Workspace tool failed"); + } + return { content: [{ type: "text", text: result.text }], details: { remote: true } }; + }, + }); + } +} +`; +} + +class PiRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private activeSettle: { resolve(): void; reject(error: Error): void } | null = null; + + onEvent: ((event: Record) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.read(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Pi RPC exited with code ${code}`))); + } + + async command(type: string, fields: Record = {}, timeoutMs = 15_000): Promise> { + if (this.closed) throw new Error("Pi RPC is closed"); + const id = `ocx-${++this.nextId}`; + const result = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${type} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ id, type, ...fields }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Pi RPC write failed")); + } + } + return result; + } + + async prompt(message: string): Promise { + if (this.activeSettle) throw new Error("Pi Remote Workspace turn is already active"); + const settled = new Promise((resolve, reject) => { this.activeSettle = { resolve, reject }; }); + try { + const accepted = await this.command("prompt", { message }); + if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); + await settled; + } catch (error) { + this.activeSettle = null; + throw error; + } + } + + async abort(): Promise { + if (!this.activeSettle) return; + await this.command("abort", {}, 3_000).catch(() => {}); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Active and pending RPC waiters cannot survive a failed process teardown. + this.fail(new Error("Pi Remote Workspace session closed")); + } + } + + private send(value: Record): void { + const line = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async read(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Pi RPC output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (!event) throw new Error("invalid Pi RPC event"); + this.receive(event); + } + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(safeError(error, "Pi RPC output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + try { while (!(await reader.read()).done) { /* drain without retaining secrets */ } } + catch { /* stdout/exit code owns the failure */ } + finally { reader.releaseLock(); } + } + + private receive(event: Record): void { + if (event.type === "response" && typeof event.id === "string") { + const pending = this.pending.get(event.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(event.id); + pending.resolve(event); + return; + } + if (event.type === "agent_settled") { + const active = this.activeSettle; + this.activeSettle = null; + active?.resolve(); + } + if (event.type === "extension_error") { + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(new Error(safeError(event.error, "Pi Remote Workspace extension failed"))); + } + this.onEvent?.(event); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(error); + } +} + +export interface PiRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class PiRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "pi" as const; + + constructor(private readonly options: PiRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("pi"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Pi is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("pi"); + if (!executable) throw new Error("Pi is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-pi-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const extensionPath = join(isolation, "remote-workspace-extension.js"); + try { + writeFileSync(extensionPath, extensionSource(options.tools), { mode: 0o600 }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const childEnv = { + ...process.env, + ...this.options.env, + OCX_REMOTE_WORKSPACE_BRIDGE_URL: bridge.url, + OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN: bridge.token, + }; + const invocation = remoteWorkspaceProcessInvocation([ + ...commandPrefix, + "--mode", "rpc", + "--session-id", threadId, + "--name", `OCX Remote: ${options.deviceName}`, + "--no-builtin-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-themes", + "--no-context-files", + "--no-approve", + "--extension", extensionPath, + "--tools", options.tools.map(tool => `remote_${tool}`).join(","), + "--system-prompt", remotePiInstructions(options.deviceName, options.tools), + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const rpc = new PiRpcProcess(child); + rpc.onEvent = event => { + if (event.type === "message_end") { + const text = messageText(event.message); + if (text) options.emit("assistant", text); + } + if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + options.emit("tool", `Pi requested ${event.toolName}`); + } + }; + try { + const state = await rpc.command("get_state"); + if (state.success !== true) throw new Error(safeError(state.error, "Pi RPC failed to initialize")); + } catch (error) { + await rpc.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let stopped = false; + let stopOperation: Promise | null = null; + return { + threadId, + prompt: text => rpc.prompt(text), + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + stopOperation = runRemoteWorkspaceCleanupSteps([ + () => rpc.abort(), + () => rpc.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-process.ts b/src/remote-control/workspace-process.ts new file mode 100644 index 0000000000..cc177e5ebc --- /dev/null +++ b/src/remote-control/workspace-process.ts @@ -0,0 +1,129 @@ +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; +import { resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation"; + +export interface RemoteWorkspaceProcessInvocationOptions { + platform?: NodeJS.Platform; + env?: Record; +} + +/** + * Preserve argv boundaries on Unix and route Windows npm `.cmd`/`.bat` shims through the + * repository's audited ComSpec escaping. `shell: true` is deliberately never used. + */ +export function remoteWorkspaceProcessInvocation( + command: readonly string[], + options: RemoteWorkspaceProcessInvocationOptions = {}, +): SpawnInvocation { + if (command.length < 1 || !command[0]) throw new Error("remote workspace process command is empty"); + return commandInvocation( + command[0], + command.slice(1), + options.platform ?? process.platform, + { env: options.env ?? process.env }, + ); +} + +export interface RemoteWorkspaceOwnedProcess { + pid: number; + exitCode: number | null; + exited: Promise; + kill(signal?: number | NodeJS.Signals): void; +} + +export interface StopRemoteWorkspaceProcessOptions { + platform?: NodeJS.Platform; + taskkillPath?: string; + execFile?: (file: string, args: readonly string[]) => void; + waitMs?: number; +} + +export async function waitForRemoteWorkspaceProcessExit( + child: RemoteWorkspaceOwnedProcess, + waitMs: number, +): Promise { + if (!Number.isSafeInteger(waitMs) || waitMs < 1) throw new Error("invalid remote workspace process wait"); + let timer: ReturnType | null = null; + try { + return await Promise.race([ + child.exited.then(() => true, () => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), waitMs); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Run every owned-resource cleanup step and report the first failure only after all were attempted. */ +export async function runRemoteWorkspaceCleanupSteps( + steps: readonly (() => void | Promise)[], +): Promise { + let failed = false; + let firstFailure: unknown; + for (const step of steps) { + try { + await step(); + } catch (error) { + if (!failed) firstFailure = error; + failed = true; + } + } + if (failed) { + throw firstFailure instanceof Error + ? firstFailure + : new Error("remote workspace cleanup failed"); + } +} + +/** Stop only the process OCX spawned; Windows must include its `.cmd` descendant tree. */ +export async function stopRemoteWorkspaceProcess( + child: RemoteWorkspaceOwnedProcess, + options: StopRemoteWorkspaceProcessOptions = {}, +): Promise { + if (child.exitCode !== null) return; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + const exec = options.execFile ?? ((file: string, args: readonly string[]) => { + execFileSync(file, [...args], { stdio: "ignore", timeout: 5_000, windowsHide: true }); + }); + try { + exec(options.taskkillPath ?? resolveTrustedWindowsTaskkillExe(), ["/PID", String(child.pid), "/T", "/F"]); + } catch { + try { child.kill(); } catch { /* child already exited */ } + } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace Windows process tree did not exit"); + } + } else { + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const exited = await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500); + if (!exited) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace process did not exit after SIGKILL"); + } + } + } +} + +/** Windows AV/indexers can retain just-exited CLI files briefly; use Node's bounded retry. */ +export function removeRemoteWorkspaceIsolation(path: string): void { + rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); +} + +/** + * [Decision Log] + * - 목적과 의도: Make Hub-owned Codex, Claude Code, and Pi processes start and stop identically + * across Linux, macOS, and Windows without leaving npm-shim descendants behind. + * - 기존 구현 및 제약 조건: Unix can spawn executable scripts directly. Windows npm exposes + * `.cmd` files that Bun cannot safely launch shell-less, and killing cmd.exe alone can orphan Node. + * - 검토한 주요 대안: `shell: true`, three runtime-specific wrappers, direct `.cmd` spawn, or the + * repository's existing escaped ComSpec invocation plus trusted System32 taskkill. + * - 선택한 방식: Share one launcher and one owned-process stop helper across all three runtimes. + * - 다른 대안 대신 이 방식을 선택한 이유: It preserves exact argv boundaries, avoids a PATH- + * resolved shell/taskkill hijack, and matches already-tested OpenCodex Windows behavior. + * - 장점, 단점 및 영향: Windows npm installs work and stop cleanly. Windows stop is necessarily + * forceful because its normal process kill is already forceful; Unix gets a graceful SIGTERM + * window and then a bounded SIGKILL fallback so an ignoring child cannot outlive the session. + */ diff --git a/src/remote-control/workspace-rpc.ts b/src/remote-control/workspace-rpc.ts new file mode 100644 index 0000000000..15f8c7a381 --- /dev/null +++ b/src/remote-control/workspace-rpc.ts @@ -0,0 +1,304 @@ +import type { RemoteControlCipher } from "./crypto"; +import type { + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +import { + isRemoteWorkspaceToolName, + remoteWorkspaceCapabilityForTool, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceTransport } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + RemoteWorkspaceRpcReassembler, + frameRemoteWorkspaceRpcMessage, +} from "./workspace-rpc-framing"; + +const REMOTE_WORKSPACE_RPC_VERSION = 1 as const; +const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 30_000; +const REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS = 8; +interface RemoteWorkspaceRpcRequest { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "request"; + request: RemoteWorkspaceExecutionRequest; +} + +interface RemoteWorkspaceRpcResponse { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "response"; + requestId: string; + result: RemoteWorkspaceToolResult; +} + +type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse; + +interface PendingRequest { + resolve(value: RemoteWorkspaceToolResult): void; + reject(error: Error): void; + timer: ReturnType; +} + +function boundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value); +} + +function encodeMessage(value: RemoteWorkspaceRpcMessage): Uint8Array { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + if (encoded.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("remote workspace RPC message exceeds the bounded message limit"); + } + return encoded; +} + +function parseResult(value: unknown): RemoteWorkspaceToolResult { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC result"); + const raw = value as Record; + if (Object.keys(raw).some(key => key !== "ok" && key !== "value" && key !== "error")) { + throw new Error("invalid remote workspace RPC result fields"); + } + if (raw.ok === true && raw.error === undefined) { + return raw.value === undefined ? { ok: true } : { ok: true, value: raw.value }; + } + if (raw.ok === false && raw.value === undefined + && typeof raw.error === "string" && raw.error.length >= 1 && raw.error.length <= 4096) { + return { ok: false, error: raw.error }; + } + throw new Error("invalid remote workspace RPC result status"); +} + +function parseRequest(value: unknown): RemoteWorkspaceExecutionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC request"); + const raw = value as Record; + if ( + !boundedIdentifier(raw.requestId) + || !boundedIdentifier(raw.sessionId) + || !boundedIdentifier(raw.executorDeviceId) + || !boundedIdentifier(raw.rootId) + || !isRemoteWorkspaceToolName(raw.tool) + ) throw new Error("invalid remote workspace RPC request identity"); + return { + requestId: raw.requestId, + sessionId: raw.sessionId, + executorDeviceId: raw.executorDeviceId, + rootId: raw.rootId, + tool: raw.tool, + arguments: raw.arguments, + }; +} + +function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage { + if (!(value instanceof Uint8Array) || value.byteLength < 1 || value.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("invalid remote workspace RPC message length"); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("invalid remote workspace RPC JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid remote workspace RPC message"); + const raw = parsed as Record; + if (raw.version !== REMOTE_WORKSPACE_RPC_VERSION) throw new Error("unsupported remote workspace RPC version"); + if (raw.kind === "request") { + return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: "request", request: parseRequest(raw.request) }; + } + if (raw.kind === "response" && boundedIdentifier(raw.requestId)) { + return { + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: raw.requestId, + result: parseResult(raw.result), + }; + } + throw new Error("invalid remote workspace RPC message kind"); +} + +export interface EncryptedRemoteWorkspaceTransportOptions { + executorDeviceId: string; + cipher: RemoteControlCipher; + sendCiphertext(value: Uint8Array): void | Promise; + timeoutMs?: number; +} + +/** Coordinator-side transport. The WebSocket/relay adapter only has to carry ciphertext. */ +export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + private readonly pending = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private readonly timeoutMs: number; + private sendTail: Promise = Promise.resolve(); + private online = true; + + constructor(private readonly options: EncryptedRemoteWorkspaceTransportOptions) { + this.timeoutMs = options.timeoutMs ?? REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS; + if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) { + throw new Error("invalid encrypted remote workspace transport options"); + } + } + + isOnline(deviceId: string): boolean { + return this.online && deviceId === this.options.executorDeviceId; + } + + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + if (!this.isOnline(request.executorDeviceId)) throw new Error("remote workspace executor is offline"); + if (this.pending.has(request.requestId)) throw new Error("duplicate remote workspace request ID"); + if (this.pending.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace request limit reached"); + } + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.requestId); + reject(new Error("remote workspace request timed out")); + }, this.timeoutMs); + this.pending.set(request.requestId, { resolve, reject, timer }); + }); + try { + await this.sendMessage(encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "request", + request, + })); + } catch { + // A failed encrypted write consumes a directional counter. Continuing would make every + // later frame undecryptable, so fail every pending operation instead of waiting for timeout. + this.close("remote workspace send failed"); + } + return await response; + } + + receiveCiphertext(value: Uint8Array): void { + if (!this.online) throw new Error("remote workspace transport is closed"); + const responsePlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!responsePlaintext) return; + const message = parseMessage(responsePlaintext); + if (message.kind !== "response") throw new Error("coordinator received a remote workspace request"); + const pending = this.pending.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message.result); + } + + close(reason = "remote workspace transport closed"): void { + if (!this.online) return; + this.online = false; + this.reassembler.clear(); + this.options.cipher.destroy(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pending.clear(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (!this.online) throw new Error("remote workspace transport is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} + +export interface EncryptedRemoteWorkspaceExecutorEndpointOptions { + executorDeviceId: string; + sessionId: string; + rootId: string; + capabilities: readonly RemoteWorkspaceCapability[]; + cipher: RemoteControlCipher; + executor: Pick; + sendCiphertext(value: Uint8Array): void | Promise; +} + +/** Executor-side endpoint. It accepts only authenticated, ordered E2EE session frames. */ +export class EncryptedRemoteWorkspaceExecutorEndpoint { + private closed = false; + private readonly active = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private sendTail: Promise = Promise.resolve(); + + private readonly grantedCapabilities: ReadonlySet; + private readonly sessionId: string; + private readonly rootId: string; + + constructor(private readonly options: EncryptedRemoteWorkspaceExecutorEndpointOptions) { + if (!boundedIdentifier(options.executorDeviceId) || !boundedIdentifier(options.sessionId) + || !boundedIdentifier(options.rootId)) throw new Error("invalid remote workspace executor endpoint"); + this.options = { ...options }; + this.sessionId = options.sessionId; + this.rootId = options.rootId; + this.grantedCapabilities = new Set(options.capabilities); + } + + async receiveCiphertext(value: Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + const requestPlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!requestPlaintext) return; + const message = parseMessage(requestPlaintext); + if (message.kind !== "request") throw new Error("executor received a remote workspace response"); + if (message.request.executorDeviceId !== this.options.executorDeviceId) { + throw new Error("remote workspace encrypted request targeted another executor"); + } + if (message.request.sessionId !== this.sessionId || message.request.rootId !== this.rootId) { + throw new Error("remote workspace encrypted request does not match its session binding"); + } + if (!this.grantedCapabilities.has(remoteWorkspaceCapabilityForTool(message.request.tool))) { + throw new Error("remote workspace tool capability was not granted to this session"); + } + if (this.active.has(message.request.requestId)) throw new Error("duplicate remote workspace executor request ID"); + if (this.active.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace executor request limit reached"); + } + const controller = new AbortController(); + this.active.set(message.request.requestId, controller); + let result: RemoteWorkspaceToolResult; + try { + result = await this.options.executor.invoke(message.request, controller.signal); + } finally { + this.active.delete(message.request.requestId); + } + if (this.closed) return; + let responsePlaintext: Uint8Array; + try { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result, + }); + } catch { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result: { ok: false, error: "remote workspace result exceeded the encrypted frame limit" }, + }); + } + await this.sendMessage(responsePlaintext); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.reassembler.clear(); + for (const controller of this.active.values()) controller.abort(); + this.active.clear(); + this.options.cipher.destroy(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} diff --git a/src/remote-control/workspace-runtime.ts b/src/remote-control/workspace-runtime.ts new file mode 100644 index 0000000000..01596753c2 --- /dev/null +++ b/src/remote-control/workspace-runtime.ts @@ -0,0 +1,60 @@ +import type { OcxConfig } from "../types"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubFileStore, + type RemoteWorkspaceHubStateStore, +} from "./workspace-hub"; +import { CodexRemoteWorkspaceRuntimeFactory } from "./workspace-codex-runtime"; +import { ClaudeRemoteWorkspaceRuntimeFactory } from "./workspace-claude-runtime"; +import { PiRemoteWorkspaceRuntimeFactory } from "./workspace-pi-runtime"; +import { + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; + +const hubs = new WeakMap(); +const sessionServices = new WeakMap(); + +export function remoteWorkspaceHubForConfig( + config: Readonly, + store?: RemoteWorkspaceHubStateStore, +): RemoteWorkspaceHub { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = hubs.get(config); + if (existing) return existing; + const hub = new RemoteWorkspaceHub(store ?? new RemoteWorkspaceHubFileStore()); + hubs.set(config, hub); + return hub; +} + +export function remoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = sessionServices.get(config); + if (existing) return existing; + const service = new RemoteWorkspaceSessionService( + remoteWorkspaceHubForConfig(config), + [ + new CodexRemoteWorkspaceRuntimeFactory(), + new ClaudeRemoteWorkspaceRuntimeFactory(), + new PiRemoteWorkspaceRuntimeFactory(), + ], + Date.now, + new RemoteWorkspaceSessionFileStore(), + ); + sessionServices.set(config, service); + return service; +} + +export function initializedRemoteWorkspaceHubForConfig( + config: Readonly, +): RemoteWorkspaceHub | null { + return hubs.get(config) ?? null; +} + +export function initializedRemoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService | null { + return sessionServices.get(config) ?? null; +} diff --git a/src/remote-control/workspace-secret-store.ts b/src/remote-control/workspace-secret-store.ts new file mode 100644 index 0000000000..9ff1c29249 --- /dev/null +++ b/src/remote-control/workspace-secret-store.ts @@ -0,0 +1,39 @@ +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +export interface WorkspaceSecretPermissions { + prepareDirectory(path: string): void; + hardenFile(path: string): void; +} + +/** Only ENOENT means first-run absence; permission failures must not reset identity. */ +export function workspaceSecretFileExists(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export const workspaceSecretPermissions: WorkspaceSecretPermissions = { + prepareDirectory(path) { + assertNotRealHomeUnderTest(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace secret directory must be a real directory"); + } + if (process.platform === "win32") hardenSecretDir(path, { required: true }); + else chmodSync(path, 0o700); + }, + hardenFile(path) { + assertNotRealHomeUnderTest(path); + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) { + throw new Error("remote workspace secret must be a private regular file"); + } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else chmodSync(path, 0o600); + }, +}; diff --git a/src/remote-control/workspace-sessions.ts b/src/remote-control/workspace-sessions.ts new file mode 100644 index 0000000000..775756200e --- /dev/null +++ b/src/remote-control/workspace-sessions.ts @@ -0,0 +1,730 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { RemoteWorkspaceCoordinator, type RemoteWorkspaceTransport } from "./workspace-coordinator"; +import type { RemoteWorkspaceHub } from "./workspace-hub"; +import { isRemoteWorkspaceAgentProfile, type RemoteWorkspaceAgentProfile } from "./workspace-agent-protocol"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; +import { runRemoteWorkspaceCleanupSteps } from "./workspace-process"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + parseRemoteWorkspaceCapabilities, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_SESSION_STATE_VERSION = 1 as const; + +export type RemoteWorkspaceSessionStatus = + | "starting" + | "ready" + | "running" + | "waiting_for_executor" + | "failed" + | "stopped"; + +export type RemoteWorkspaceAccessMode = "read-only" | "workspace"; + +export interface RemoteWorkspaceSessionEvent { + sequence: number; + at: string; + type: "status" | "assistant" | "tool" | "error"; + text: string; +} + +export interface RemoteWorkspaceSessionSummary { + id: string; + profile: RemoteWorkspaceAgentProfile; + accessMode: RemoteWorkspaceAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + threadId: string | null; + /** True only after the runtime has created durable history that can be resumed. */ + resumable: boolean; + status: RemoteWorkspaceSessionStatus; + createdAt: string; + updatedAt: string; + events: RemoteWorkspaceSessionEvent[]; +} + +export interface RemoteWorkspaceRuntimeHandle { + threadId: string; + canResume?(): boolean; + prompt(text: string): Promise; + stop(): Promise; +} + +export interface RemoteWorkspaceRuntimeFactory { + profile: RemoteWorkspaceAgentProfile; + available(): Promise<{ available: boolean; version?: string; reason?: string }>; + start(options: { + sessionId: string; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + resumeThreadId?: string; + coordinator: RemoteWorkspaceCoordinator; + emit(type: RemoteWorkspaceSessionEvent["type"], text: string): void; + }): Promise; +} + +export interface RemoteWorkspaceSessionState { + version: typeof REMOTE_WORKSPACE_SESSION_STATE_VERSION; + sessions: RemoteWorkspaceSessionSummary[]; +} + +export interface RemoteWorkspaceSessionStateStore { + load(): RemoteWorkspaceSessionState | null; + save(state: RemoteWorkspaceSessionState): void; +} + +interface LiveSession extends RemoteWorkspaceSessionSummary { + handle: RemoteWorkspaceRuntimeHandle | null; + unregister: (() => void) | null; + closeTransport: (() => Promise) | null; + operation: Promise; + stopOperation: Promise | null; + remoteTransport: SwitchableRemoteWorkspaceTransport | null; + turnActive: boolean; +} + +const MAX_EVENTS_PER_SESSION = 100; +const MAX_EVENT_TEXT_BYTES = 8 * 1024; +const MAX_PROMPT_BYTES = 256 * 1024; +const MAX_LIVE_SESSIONS = 8; +const MAX_RETAINED_SESSIONS = 64; +const MAX_LIST_EVENTS_PER_SESSION = 20; +const MAX_PERSISTED_EVENTS_PER_SESSION = 40; +const MAX_PERSISTED_EVENT_TEXT_BYTES = 4 * 1024; +const MAX_SESSION_STATE_BYTES = 16 * 1024 * 1024; +const AVAILABILITY_CACHE_MS = 30_000; +type RuntimeAvailability = Record; + +class SwitchableRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + constructor(private current: RemoteWorkspaceTransport) {} + + replace(next: RemoteWorkspaceTransport): void { + this.current = next; + } + + isOnline(deviceId: string): boolean { + return this.current.isOnline(deviceId); + } + + invoke(request: RemoteWorkspaceExecutionRequest): Promise { + return this.current.invoke(request); + } +} + +function boundedPrompt(value: unknown): string { + if (typeof value !== "string" || value.trim().length < 1 || Buffer.byteLength(value, "utf8") > MAX_PROMPT_BYTES) { + throw new Error("remote workspace prompt must contain 1 to 262144 UTF-8 bytes"); + } + return value; +} + +function boundedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated]"; + return `${truncateRemoteWorkspaceUtf8(value, MAX_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"))}${marker}`; +} + +function boundedPersistedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_PERSISTED_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated for restart snapshot]"; + const maximum = MAX_PERSISTED_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(value, maximum)}${marker}`; +} + +function boundedString(value: unknown, label: string, maximum = 256): string { + if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function timestamp(value: unknown): string { + const result = boundedString(value, "timestamp", 64); + if (!Number.isFinite(Date.parse(result))) throw new Error("invalid remote workspace timestamp"); + return result; +} + +function parseStatus(value: unknown): RemoteWorkspaceSessionStatus { + if (value === "starting" || value === "ready" || value === "running" + || value === "waiting_for_executor" || value === "failed" || value === "stopped") return value; + throw new Error("invalid remote workspace session status"); +} + +function parseAccessMode(value: unknown): RemoteWorkspaceAccessMode { + if (value === undefined || value === "workspace") return "workspace"; + if (value === "read-only") return value; + throw new Error("invalid remote workspace access mode"); +} + +function parseEvent(value: unknown): RemoteWorkspaceSessionEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session event"); + const raw = value as Record; + if (typeof raw.sequence !== "number" || !Number.isSafeInteger(raw.sequence) || raw.sequence < 1) { + throw new Error("invalid remote workspace event sequence"); + } + if (raw.type !== "status" && raw.type !== "assistant" && raw.type !== "tool" && raw.type !== "error") { + throw new Error("invalid remote workspace event type"); + } + return { + sequence: raw.sequence, + at: timestamp(raw.at), + type: raw.type, + text: boundedString(raw.text, "event text", MAX_EVENT_TEXT_BYTES), + }; +} + +function parseSession(value: unknown): RemoteWorkspaceSessionSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session"); + const raw = value as Record; + if (!isRemoteWorkspaceAgentProfile(raw.profile)) throw new Error("invalid remote workspace session profile"); + const accessMode = parseAccessMode(raw.accessMode); + const capabilities = parseRemoteWorkspaceCapabilities(raw.capabilities); + if (accessMode === "read-only" + && (capabilities.length !== 1 || capabilities[0] !== "workspace.read")) { + throw new Error("read-only remote workspace state contains write capabilities"); + } + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (!Array.isArray(raw.events) || raw.events.length > MAX_EVENTS_PER_SESSION) { + throw new Error("invalid remote workspace session events"); + } + if (raw.threadId !== null && typeof raw.threadId !== "string") throw new Error("invalid remote workspace thread ID"); + const resumable = raw.resumable === undefined + ? raw.threadId !== null + : raw.resumable === true; + if (raw.resumable !== undefined && typeof raw.resumable !== "boolean") { + throw new Error("invalid remote workspace resumable state"); + } + if (resumable && raw.threadId === null) throw new Error("resumable remote workspace session has no thread ID"); + return { + id: boundedString(raw.id, "session ID"), + profile: raw.profile, + accessMode, + deviceId: boundedString(raw.deviceId, "device ID"), + deviceName: boundedString(raw.deviceName, "device name", 80), + rootId: boundedString(raw.rootId, "root ID"), + rootLabel: boundedString(raw.rootLabel, "root label", 80), + capabilities, + tools, + threadId: raw.threadId === null ? null : boundedString(raw.threadId, "thread ID"), + resumable, + status: parseStatus(raw.status), + createdAt: timestamp(raw.createdAt), + updatedAt: timestamp(raw.updatedAt), + events: raw.events.map(parseEvent), + }; +} + +export function parseRemoteWorkspaceSessionState(value: unknown): RemoteWorkspaceSessionState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_SESSION_STATE_VERSION || !Array.isArray(raw.sessions)) { + throw new Error("unsupported remote workspace session state"); + } + if (raw.sessions.length > MAX_RETAINED_SESSIONS) throw new Error("remote workspace retained session limit exceeded"); + const ids = new Set(); + const sessions = raw.sessions.map(item => { + const session = parseSession(item); + if (ids.has(session.id)) throw new Error("duplicate remote workspace session ID"); + ids.add(session.id); + return session; + }); + return { version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }; +} + +export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-sessions.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceSessionState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + return parseRemoteWorkspaceSessionState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceSessionState): void { + const parsed = parseRemoteWorkspaceSessionState(state); + const body = `${JSON.stringify(parsed, null, 2)}\n`; + if (Buffer.byteLength(body, "utf8") > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, body); + } +} + +export class RemoteWorkspaceSessionService { + private readonly sessions = new Map(); + private readonly runtimes = new Map(); + private sequence = 0; + private availabilityCache: { at: number; value: RuntimeAvailability } | null = null; + private availabilityFlight: Promise | null = null; + + constructor( + private readonly hub: RemoteWorkspaceHub, + factories: readonly RemoteWorkspaceRuntimeFactory[], + private readonly now: () => number = Date.now, + private readonly store?: RemoteWorkspaceSessionStateStore, + ) { + for (const factory of factories) { + if (this.runtimes.has(factory.profile)) throw new Error("duplicate remote workspace runtime profile"); + this.runtimes.set(factory.profile, factory); + } + for (const summary of this.store?.load()?.sessions ?? []) { + const restoredStatus = summary.status === "stopped" + ? "stopped" + : summary.threadId && summary.resumable + ? "waiting_for_executor" + : "failed"; + this.sessions.set(summary.id, { + ...summary, + status: restoredStatus, + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }); + for (const event of summary.events) this.sequence = Math.max(this.sequence, event.sequence); + } + } + + async availability(): Promise { + if (this.availabilityCache && this.now() - this.availabilityCache.at < AVAILABILITY_CACHE_MS) { + return structuredClone(this.availabilityCache.value); + } + if (this.availabilityFlight) return structuredClone(await this.availabilityFlight); + this.availabilityFlight = (async () => { + const probe = async (profile: RemoteWorkspaceAgentProfile) => { + const factory = this.runtimes.get(profile); + if (!factory) return { available: false, reason: "runtime adapter is not installed" }; + try { return await factory.available(); } + catch { return { available: false, reason: "runtime availability probe failed" }; } + }; + const [codex, claude, pi] = await Promise.all([ + probe("codex"), + probe("claude"), + probe("pi"), + ]); + const value: RuntimeAvailability = { codex, claude, pi }; + this.availabilityCache = { at: this.now(), value }; + return value; + })(); + try { return structuredClone(await this.availabilityFlight); } + finally { this.availabilityFlight = null; } + } + + list(): RemoteWorkspaceSessionSummary[] { + this.refreshOfflineStates(); + return [...this.sessions.values()].map(session => this.publicSession(session, MAX_LIST_EVENTS_PER_SESSION)); + } + + get(sessionId: string): RemoteWorkspaceSessionSummary | null { + this.refreshOfflineStates(); + const session = this.sessions.get(sessionId); + return session ? this.publicSession(session) : null; + } + + async create(input: { + profile: RemoteWorkspaceAgentProfile; + deviceId: string; + rootId: string; + accessMode?: RemoteWorkspaceAccessMode; + }): Promise { + this.pruneRetainedSessions(); + const liveCount = [...this.sessions.values()].filter(session => session.handle !== null).length; + if (liveCount >= MAX_LIVE_SESSIONS) throw new Error("remote workspace active session limit reached"); + const deviceLiveCount = [...this.sessions.values()].filter(session => ( + session.deviceId === input.deviceId && session.handle !== null + )).length; + if (deviceLiveCount >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + const factory = this.runtimes.get(input.profile); + if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); + const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); + if (!device) throw new Error("remote workspace device not found"); + const root = device.roots.find(candidate => candidate.id === input.rootId); + if (!root) throw new Error("remote workspace root not found on the selected device"); + const connection = this.hub.connection(device.id); + if (!connection) throw new Error("remote workspace executor is offline"); + const id = randomUUID(); + const accessMode = parseAccessMode(input.accessMode ?? "read-only"); + const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); + const capabilities = accessMode === "read-only" + ? parseRemoteWorkspaceCapabilities(["workspace.read"]) + : deviceCapabilities; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + const connectionCapabilities = connection.capabilities(); + if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); + } + const timestamp = new Date(this.now()).toISOString(); + const session: LiveSession = { + id, + profile: input.profile, + accessMode, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + threadId: null, + resumable: false, + status: "starting", + createdAt: timestamp, + updatedAt: timestamp, + events: [], + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }; + this.sessions.set(id, session); + this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + try { + this.persist(); + } catch (error) { + this.sessions.delete(id); + throw error; + } + try { + session.closeTransport = () => connection.closeSession(id); + const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); + if (session.stopOperation) { + await session.closeTransport().catch(() => {}); + session.closeTransport = null; + throw new Error("remote workspace session was stopped while starting"); + } + const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.remoteTransport = remoteTransport; + const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); + const handle = await factory.start({ + sessionId: id, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + if (session.stopOperation) { + await handle.stop().catch(() => {}); + throw new Error("remote workspace session was stopped while starting"); + } + session.threadId = handle.threadId; + session.resumable = handle.canResume?.() ?? true; + session.handle = handle; + session.unregister = coordinator.register({ + sessionId: id, + threadId: handle.threadId, + executorDeviceId: device.id, + executorName: device.name, + rootId: root.id, + capabilities, + tools, + }); + this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); + return this.publicSession(session); + } catch (error) { + let reported = error; + if (session.status !== "stopped") { + try { + this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); + } catch (persistenceError) { + reported = persistenceError; + } + } + session.unregister?.(); + session.unregister = null; + await session.handle?.stop().catch(() => {}); + session.handle = null; + await session.closeTransport?.().catch(() => {}); + session.closeTransport = null; + session.remoteTransport = null; + throw reported; + } + } + + async prompt(sessionId: string, value: unknown): Promise { + const prompt = boundedPrompt(value); + const session = this.sessions.get(sessionId); + if (!session || session.status === "stopped") throw new Error("remote workspace session is not ready"); + if (!session.handle && (!session.threadId || !session.resumable)) { + throw new Error("remote workspace session cannot be resumed"); + } + if (session.turnActive) throw new Error("remote workspace session already has an active turn"); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + session.turnActive = true; + const run = async () => { + try { + await this.ensureRemoteTransport(session); + await this.ensureRuntime(session); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + this.status(session, "running", "Turn started"); + await session.handle!.prompt(prompt); + session.resumable = session.handle!.canResume?.() ?? true; + if (!this.hub.connection(session.deviceId) + || !session.remoteTransport?.isOnline(session.deviceId)) { + this.status(session, "waiting_for_executor", "Turn completed; reconnect the remote executor before continuing."); + } else { + this.status(session, "ready", "Turn completed"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "remote workspace turn failed"; + this.status(session, this.hub.connection(session.deviceId) ? "failed" : "waiting_for_executor", message); + throw error; + } finally { + session.turnActive = false; + } + }; + session.operation = run(); + await session.operation; + return this.publicSession(session); + } + + async stop(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.stopOperation) return session.stopOperation; + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + // Cancellation has to run before waiting for the active turn. Waiting first makes + // Stop unable to interrupt a model request or remote command that never completes. + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Session cleanup failed; one or more owned resources did not close."); + throw error; + } + this.status(session, "stopped", "Session stopped"); + return true; + })(); + return session.stopOperation; + } + + async stopAll(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(session => this.stop(session.id).then(() => undefined))); + this.persist(); + } + + async shutdown(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(async session => { + if (session.stopOperation) { + await session.stopOperation; + return; + } + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Hub shutdown could not close every Remote Workspace resource."); + throw error; + } + this.status( + session, + session.threadId && session.resumable ? "waiting_for_executor" : "failed", + session.threadId && session.resumable + ? "Hub stopped; reconnect the executor to resume this session." + : "Hub stopped before the model session was created.", + ); + return true; + })(); + await session.stopOperation; + })); + this.persist(); + } + + private status(session: LiveSession, status: RemoteWorkspaceSessionStatus, text: string): void { + session.status = status; + this.emit(session, status === "failed" ? "error" : "status", text); + this.persist(); + } + + private emit(session: LiveSession, type: RemoteWorkspaceSessionEvent["type"], text: string): void { + const at = new Date(this.now()).toISOString(); + session.updatedAt = at; + session.events.push({ sequence: ++this.sequence, at, type, text: boundedEventText(text) }); + if (session.events.length > MAX_EVENTS_PER_SESSION) { + session.events.splice(0, session.events.length - MAX_EVENTS_PER_SESSION); + } + } + + private publicSession(session: LiveSession, eventLimit = MAX_EVENTS_PER_SESSION): RemoteWorkspaceSessionSummary { + const { + handle: _handle, + unregister: _unregister, + closeTransport: _close, + operation: _operation, + stopOperation: _stopOperation, + remoteTransport: _remoteTransport, + turnActive: _turnActive, + ...publicState + } = session; + return structuredClone({ ...publicState, events: publicState.events.slice(-eventLimit) }); + } + + private async ensureRemoteTransport(session: LiveSession): Promise { + if (session.remoteTransport?.isOnline(session.deviceId)) return; + const connection = this.hub.connection(session.deviceId); + if (!connection) { + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + throw new Error("remote workspace executor is offline"); + } + const connectionCapabilities = connection.capabilities(); + if (session.capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capabilities changed; start a new session for this computer"); + } + this.status(session, "starting", `Reconnecting ${session.deviceName}/${session.rootLabel}`); + const transport = await connection.openSession({ + sessionId: session.id, + rootId: session.rootId, + profile: session.profile, + capabilities: session.capabilities, + }); + if (session.stopOperation) { + await connection.closeSession(session.id).catch(() => {}); + throw new Error("remote workspace session is stopping"); + } + await session.closeTransport?.().catch(() => {}); + if (session.remoteTransport) session.remoteTransport.replace(transport); + else session.remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.closeTransport = () => connection.closeSession(session.id); + this.status(session, "ready", `${session.profile} reconnected to ${session.deviceName}/${session.rootLabel}`); + } + + private async ensureRuntime(session: LiveSession): Promise { + if (session.handle) return; + if (!session.threadId || !session.resumable || !session.remoteTransport) { + throw new Error("remote workspace session cannot be resumed"); + } + const factory = this.runtimes.get(session.profile); + if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); + const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); + const handle = await factory.start({ + sessionId: session.id, + deviceId: session.deviceId, + deviceName: session.deviceName, + rootId: session.rootId, + rootLabel: session.rootLabel, + capabilities: [...session.capabilities], + tools: [...session.tools], + resumeThreadId: session.threadId, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + try { + session.unregister = coordinator.register({ + sessionId: session.id, + threadId: handle.threadId, + executorDeviceId: session.deviceId, + executorName: session.deviceName, + rootId: session.rootId, + capabilities: [...session.capabilities], + tools: [...session.tools], + }); + } catch (error) { + await handle.stop().catch(() => {}); + throw error; + } + session.threadId = handle.threadId; + session.handle = handle; + this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); + } + + private refreshOfflineStates(): void { + for (const session of this.sessions.values()) { + if (session.status !== "ready" || this.hub.connection(session.deviceId)) continue; + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + } + } + + private pruneRetainedSessions(): void { + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + for (const [id, session] of this.sessions) { + if (session.status !== "stopped" && !(session.status === "failed" && session.handle === null)) continue; + this.sessions.delete(id); + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + } + if (this.sessions.size >= MAX_RETAINED_SESSIONS) { + throw new Error("remote workspace retained session limit reached; stop an active session first"); + } + } + + private persist(): void { + if (!this.store) return; + const sessions = [...this.sessions.values()].map(session => { + const summary = this.publicSession(session); + return { + ...summary, + events: summary.events.slice(-MAX_PERSISTED_EVENTS_PER_SESSION).map(event => ({ + ...event, + text: boundedPersistedEventText(event.text), + })), + }; + }); + this.store.save({ version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }); + } +} diff --git a/src/remote-control/workspace-tool-bridge.ts b/src/remote-control/workspace-tool-bridge.ts new file mode 100644 index 0000000000..7a3b6306a3 --- /dev/null +++ b/src/remote-control/workspace-tool-bridge.ts @@ -0,0 +1,192 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RemoteWorkspaceCoordinator } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + isRemoteWorkspaceToolName, + type RemoteWorkspaceToolName, +} from "./workspace-tools"; + +const MAX_BRIDGE_BODY_BYTES = 512 * 1024; +const MAX_BRIDGE_ACTIVE_REQUESTS = 8; +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "cache-control": "no-store" } }); +} + +function errorText(value: unknown): string { + return (value instanceof Error ? value.message : "Remote Workspace tool failed") + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +async function readBoundedJson(req: Request): Promise { + if (!req.body) throw new Error("invalid JSON"); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BRIDGE_BODY_BYTES) { + await reader.cancel("request too large").catch(() => {}); + throw new Error("request too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); +} + +export interface RemoteWorkspaceToolBridge { + url: string; + token: string; + stop(): Promise; +} + +/** + * Loopback-only bridge used by Hub-owned CLIs whose extension boundary is HTTP. + * The random bearer is passed only to the child process. The model sees tool schemas, + * never this endpoint or token, and every invocation still goes through the E2EE coordinator. + */ +export function startRemoteWorkspaceToolBridge(options: { + coordinator: RemoteWorkspaceCoordinator; + threadId: string | (() => string); + tools: readonly RemoteWorkspaceToolName[]; + onTool?: (tool: RemoteWorkspaceToolName) => void; +}): RemoteWorkspaceToolBridge { + const token = randomBytes(32).toString("base64url"); + const toolNames = new Set(options.tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => toolNames.has(tool.name)); + if (definitions.length < 1) throw new Error("Remote Workspace bridge needs at least one tool"); + const invoke = async (tool: unknown, args: unknown): Promise<{ success: boolean; text: string }> => { + if (!isRemoteWorkspaceToolName(tool) || !toolNames.has(tool)) { + return { success: false, text: JSON.stringify({ ok: false, error: "unknown Remote Workspace tool" }) }; + } + options.onTool?.(tool); + const threadId = typeof options.threadId === "function" ? options.threadId() : options.threadId; + if (!threadId) return { success: false, text: JSON.stringify({ ok: false, error: "remote workspace thread is not ready" }) }; + const result = await options.coordinator.handle({ + method: "item/tool/call", + id: randomUUID(), + params: { + threadId, + turnId: randomUUID(), + callId: randomUUID(), + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { success: result.result.success, text: result.result.contentItems[0]!.text }; + }; + let activeRequests = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("origin")) return json({ error: "browser origins are not allowed" }, 403); + if (req.headers.get("authorization") !== `Bearer ${token}`) return json({ error: "unauthorized" }, 401); + if (req.method !== "POST" || (url.pathname !== "/invoke" && url.pathname !== "/mcp")) { + return json({ error: "not found" }, 404); + } + if (activeRequests >= MAX_BRIDGE_ACTIVE_REQUESTS) return json({ error: "Remote Workspace bridge is busy" }, 429); + activeRequests += 1; + try { + const length = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(length) || length > MAX_BRIDGE_BODY_BYTES) return json({ error: "request too large" }, 413); + let parsed: unknown; + try { parsed = await readBoundedJson(req); } + catch (error) { + return json({ error: error instanceof Error && error.message === "request too large" ? error.message : "invalid JSON" }, + error instanceof Error && error.message === "request too large" ? 413 : 400); + } + const body = record(parsed); + if (!body) return json({ error: "invalid request" }, 400); + + if (url.pathname === "/invoke") { + try { + return json(await invoke(body.tool, body.arguments)); + } catch (error) { + return json({ success: false, text: JSON.stringify({ ok: false, error: errorText(error) }) }, 502); + } + } + + const id = body.id; + const method = body.method; + const params = record(body.params) ?? {}; + if (typeof method !== "string") return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_600, message: "invalid MCP request" } }); + if (method === "notifications/initialized") return new Response(null, { status: 202 }); + if (method === "initialize") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "opencodex-remote-workspace", version: "1" }, + }, + }); + } + if (method === "ping") return json({ jsonrpc: "2.0", id: id ?? null, result: {} }); + if (method === "tools/list") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + tools: definitions.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }, + }); + } + if (method === "tools/call") { + try { + const called = await invoke(params.name, params.arguments); + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: called.text }], isError: !called.success }, + }); + } catch (error) { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: errorText(error) }], isError: true }, + }); + } + } + return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_601, message: "MCP method not found" } }); + } finally { + activeRequests -= 1; + } + }, + }); + let stopping: Promise | null = null; + return { + url: new URL("/", server.url).toString().replace(/\/$/, ""), + token, + stop() { + stopping ??= server.stop(true); + return stopping; + }, + }; +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..643eb77ee6 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](../remote-workspace.md). diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index ae71389ad7..9c9f2bd786 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -168,3 +168,5 @@ pin one legacy root owner before changing it. Sibling stores remain independent. precede coordinated writes under one scoped flight, and actual file state/refusals remain separate. Restore reconciles target intent from validated snapshot ownership without changing sibling policy. Profile journal views retain source-store provenance for older legacy entries. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..dfcb13f15e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](remote-workspace.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..fb638ee220 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..b408487d18 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..be1af80293 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 534c649471..cb18d70589 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -1,11 +1,17 @@ -# Remote Workspace protocol +# Remote Workspace -`src/remote-control/` is an inactive protocol library. Importing it registers no HTTP route, opens no connection and starts no process or timer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. +`src/remote-control/` owns Remote Workspace contracts, explicit executor construction and Hub session adapters. No module is registered with server startup in this layer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. -`src/remote-control/protocol.ts` owns versioned frame, identity and capability contracts. `src/remote-control/crypto.ts` uses Ed25519 signatures, P-256 ephemeral agreement and directional AES-GCM counters. `src/remote-control/workspace-agent-protocol.ts` bounds and parses control envelopes. `src/remote-control/workspace-tools.ts` describes the remote tool namespace and capability mapping. +`src/remote-control/protocol.ts` owns frame and identity contracts. `src/remote-control/crypto.ts` implements signed handshakes and directional encryption. `src/remote-control/workspace-agent-protocol.ts` parses bounded control messages; `src/remote-control/workspace-rpc-framing.ts` bounds reassembly allocation, count and expiry. Importing these modules starts no process or timer; incomplete reassembly owns expiry timers after an explicit call. -`src/remote-control/workspace-rpc-framing.ts` fragments logical messages and bounds reassembly size, count and expiry. Expiry timers exist only after explicit incomplete-fragment acceptance. `src/remote-control/workspace-utf8.ts` bounds text without splitting surrogate pairs. +`src/remote-control/workspace-agent-connection.ts` intersects presence with enrollment authority and negotiates explicit session grants. `src/remote-control/workspace-rpc.ts` snapshots session/device/root/capabilities and rejects mismatches before invoking the executor. The paired Hub is trusted to select an approved root over authenticated WSS; workspace control traffic is not an untrusted opaque relay protocol. -`src/remote-control/host.ts` accepts an explicitly supplied terminal factory. Authenticated application traffic can invoke that factory; no production factory is supplied here. `src/remote-control/relay.ts` forwards opaque envelopes after its caller authorizes the peer. Neither adapter is wired into server startup. +`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. -The public exports in `src/remote-control/index.ts` expose only this foundation. Device enrollment, executor operations and UI activation are not part of this layer. Tests in `tests/clients/remote-control-prototype.test.ts`, `tests/clients/remote-workspace-rpc-framing.test.ts` and `tests/clients/remote-workspace-protocol.test.ts` cover the protocol contracts; they do not prove platform command confinement. +`src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. + +`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains explicit executor pair/agent/status handling; it is not yet registered by this layer. + +The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. + +Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..e71dad34f0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..839cd4a82a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..850b276cda 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/tests/clients/remote-workspace-agent-wire.test.ts b/tests/clients/remote-workspace-agent-wire.test.ts new file mode 100644 index 0000000000..0b4f2c9f95 --- /dev/null +++ b/tests/clients/remote-workspace-agent-wire.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceExecutor, + RemoteWorkspaceExecutorAgentConnection, + RemoteWorkspaceHubAgentConnection, + RemoteControlClientHandshake, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceControlSocket, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(commandRunner?: RemoteWorkspaceCommandRunner) { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-wire-")); + roots.push(root); + const workspace = join(root, "computer-2"); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, "marker.txt"), "computer-2-only"); + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: workspace }], + commandRunner, + }); + let hub: RemoteWorkspaceHubAgentConnection; + let agent: RemoteWorkspaceExecutorAgentConnection; + const hubSocket: RemoteWorkspaceControlSocket = { + send(value) { void agent.receive(value); }, + close: () => agent.close(), + }; + const agentSocket: RemoteWorkspaceControlSocket = { + send: value => hub.receive(value), + close: () => hub.close(), + }; + hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: hubSocket, + sessionOpenTimeoutMs: 1_000, + }); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: agentSocket, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + })); + return { hub, agent, workspace, deviceId }; +} + +describe("remote workspace agent wire", () => { + test("does not become online or accept session traffic before capability presence", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: () => {}, close: () => {} }, + }); + expect(hub.isOnline()).toBe(false); + await expect(hub.openSession({ sessionId: randomUUID(), rootId: "workspace", profile: "codex", capabilities: hub.capabilities() })) + .rejects.toThrow("offline"); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(hub.isOnline()).toBe(true); + expect(() => hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + }))).toThrow("duplicate presence"); + hub.close(); + }); + + test("cancels a session handshake immediately instead of waiting for its timeout", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const sent: string[] = []; + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: value => { sent.push(value); }, close: () => {} }, + sessionOpenTimeoutMs: 30_000, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + const sessionId = randomUUID(); + const opening = hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: hub.capabilities() }); + await hub.closeSession(sessionId, "cancelled by user"); + await expect(opening).rejects.toThrow("cancelled by user"); + expect(sent.map(message => parseRemoteWorkspaceHubMessage(message).type)) + .toEqual(["presence_ack", "session_open", "session_close"]); + hub.close(); + }); + + test("opens an authenticated encrypted session and executes on the OCX-only device", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ + sessionId, + rootId: "workspace", + profile: "codex", capabilities: state.hub.capabilities() }); + const result = await transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "computer-2-only" } }); + await state.hub.closeSession(sessionId); + expect(transport.isOnline(state.deviceId)).toBe(false); + }); + + test("discards an endpoint when sending session acceptance fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-accept-failure-")); + roots.push(root); + const deviceId = randomUUID(); + const sessionId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: root }], + }); + const handshake = RemoteControlClientHandshake.create({ + sessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: hubIdentity.privateKey, + }); + const sent: string[] = []; + let failAcceptance = true; + const agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: ["workspace.read", "workspace.write"], + socket: { + send(value) { + const message = parseRemoteWorkspaceAgentMessage(value); + if (message.type === "session_accept" && failAcceptance) { + failAcceptance = false; + throw new Error("socket send failed"); + } + sent.push(value); + }, + close() {}, + }, + }); + const open = serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: "workspace", + clientHello: handshake.hello, + }); + await agent.receive(open); + await agent.receive(open); + expect(sent.map(value => parseRemoteWorkspaceAgentMessage(value).type)) + .toEqual(["session_reject", "session_accept"]); + agent.close(); + }); + + test("fails pending and active work closed when the executor disconnects", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "pi", capabilities: state.hub.capabilities() }); + state.hub.close("executor disconnected"); + expect(transport.isOnline(state.deviceId)).toBe(false); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + })).rejects.toThrow("offline"); + }); + + test("session close aborts an active command on the executor", async () => { + let started!: () => void; + const active = new Promise(resolve => { started = resolve; }); + let cancelled = false; + const state = fixture({ + async run(request) { + started(); + return await new Promise((_resolve, reject) => { + const abort = () => { + cancelled = true; + reject(new Error("cancelled")); + }; + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const invocation = transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["sleep", "60"] }, + }); + await active; + await state.hub.closeSession(sessionId); + await expect(invocation).rejects.toThrow("closed"); + await Bun.sleep(5); + expect(cancelled).toBe(true); + }); + + test("bounds concurrent Hub requests while serializing operations on one executor", async () => { + let started = 0; + const state = fixture({ + async run(request) { + started += 1; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new Error("cancelled")); + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const pending = Array.from({ length: 8 }, () => transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["wait"] }, + }).catch(error => error)); + for (let count = 0; count < 100 && started < 1; count += 1) await Bun.sleep(1); + expect(started).toBe(1); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["overflow"] }, + })).rejects.toThrow("request limit"); + await state.hub.closeSession(sessionId); + await Promise.all(pending); + }); + + test("rejects malformed, oversized, and non-workspace control messages", () => { + expect(() => parseRemoteWorkspaceHubMessage("{}")) + .toThrow("unsupported remote workspace agent protocol"); + expect(() => parseRemoteWorkspaceAgentMessage(JSON.stringify({ + version: 1, + type: "heartbeat", + nonce: "ok", + extra: true, + }))).toThrow("fields"); + expect(() => parseRemoteWorkspaceAgentMessage("x".repeat(100 * 1024))) + .toThrow("length"); + }); +}); + +test("a read-only negotiated session rejects writes before touching its approved root", async () => { + const state = fixture(); + const sessionId = randomUUID(); + try { + const transport = await state.hub.openSession({ + sessionId, rootId: "workspace", profile: "codex", capabilities: ["workspace.read"], + }); + const result = await transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "read_file", arguments: { path: "marker.txt" }, + }); + expect(result.ok).toBe(true); + await expect(transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "write_file", arguments: { path: "new.txt", content: "denied", expectedSha256: null }, + })).rejects.toThrow(); + } finally { state.hub.close(); state.agent.close(); } +}); diff --git a/tests/clients/remote-workspace-app-server.integration.test.ts b/tests/clients/remote-workspace-app-server.integration.test.ts new file mode 100644 index 0000000000..54f7115452 --- /dev/null +++ b/tests/clients/remote-workspace-app-server.integration.test.ts @@ -0,0 +1,426 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + startRemoteWorkspaceToolBridge, + type RemoteWorkspaceTransport, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +interface JsonMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: Record; +} + +interface CapturedResponsesRequest { + input?: Array>; + tools?: Array>; +} + +function sse(events: unknown[]): string { + return events.map(event => { + const type = (event as { type: string }).type; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }).join(""); +} + +function completed(id: string): unknown { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function responseCreated(id: string): unknown { + return { type: "response.created", response: { id } }; +} + +class JsonLinePeer { + private readonly reader: ReadableStreamDefaultReader; + private buffer = ""; + + constructor( + stdout: ReadableStream, + private readonly stdin: FileSink, + ) { + this.reader = stdout.getReader(); + } + + send(message: unknown): void { + this.stdin.write(`${JSON.stringify(message)}\n`); + this.stdin.flush(); + } + + async next(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline >= 0) { + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + return JSON.parse(line) as JsonMessage; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("timed out waiting for Codex App Server JSON-RPC"); + const next = await Promise.race([ + this.reader.read(), + new Promise((_, reject) => setTimeout( + () => reject(new Error("timed out waiting for Codex App Server output")), + remaining, + )), + ]); + if (next.done) throw new Error("Codex App Server closed its output"); + this.buffer += new TextDecoder().decode(next.value, { stream: true }); + } + } + + async waitFor(predicate: (message: JsonMessage) => boolean): Promise { + for (let count = 0; count < 200; count += 1) { + const message = await this.next(); + if (predicate(message)) return message; + } + throw new Error("Codex App Server did not emit the expected message"); + } +} + +const codexBin = process.env.OCX_CODEX_BIN; +const appServerTest = codexBin ? test : test.skip; + +const localIntegrationCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local integration command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local integration command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +appServerTest("real Codex App Server delegates a dynamic workspace tool to Computer 2", async () => { + if (!codexBin || !existsSync(codexBin)) throw new Error("OCX_CODEX_BIN must identify a real Codex executable"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-app-server-")); + const mainHome = join(root, "main-home"); + const mainCodexHome = join(root, "main-codex"); + const mainOcxHome = join(root, "main-ocx"); + const sandboxBin = join(root, "sandbox-bin"); + const coordinatorIsolation = join(root, "coordinator-isolation"); + const executorRoot = join(root, "computer-2-workspace"); + const hubSecret = join(root, "hub-secret.txt"); + for (const path of [mainHome, mainCodexHome, mainOcxHome, coordinatorIsolation, executorRoot, sandboxBin]) { + mkdirSync(path, { recursive: true }); + } + linkSync(codexBin, join(sandboxBin, "codex-linux-sandbox")); + writeFileSync(join(coordinatorIsolation, "integration-marker.txt"), "main-unchanged"); + writeFileSync(hubSecret, "HUB-SECRET-MUST-NOT-LEAK"); + + const requestBodies: unknown[] = []; + let responseIndex = 0; + const modelServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (request.method !== "POST" || !url.pathname.endsWith("/responses")) { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const requestBody = await request.json() as CapturedResponsesRequest; + requestBodies.push(requestBody); + responseIndex += 1; + if (responseIndex === 1) { + const hasCodeMode = JSON.stringify(requestBody.input).includes('"name":"functions"') + && JSON.stringify(requestBody.input).includes('"name":"exec"'); + if (!hasCodeMode) { + return new Response(sse([ + responseCreated("resp-remote-no-tool"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-no-remote-tool", + content: [{ type: "output_text", text: "Remote tool unavailable" }], + }, + }, + completed("resp-remote-no-tool"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return new Response(sse([ + responseCreated("resp-remote-1"), + { + type: "response.output_item.done", + item: { + type: "custom_tool_call", + call_id: "remote-exec-call", + namespace: "functions", + name: "exec", + input: [ + "const result = await tools.mcp__ocx_remote_workspace__exec({", + " command: ['/bin/sh', '-lc', \"printf 'computer-2' > integration-marker.txt; printf 'executor-cwd:'; pwd\"],", + " cwd: '.',", + " timeoutMs: 5000,", + "});", + "let localProbe;", + `try { localProbe = await tools.exec_command({ cmd: ${JSON.stringify(`cat -- ${JSON.stringify(hubSecret)}`)} }); }`, + "catch (error) { localProbe = String(error); }", + "text(JSON.stringify({ result, localProbe }));", + ].join("\n"), + }, + }, + completed("resp-remote-1"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + if (responseIndex === 2) { + return new Response(sse([ + responseCreated("resp-remote-2"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-remote-done", + content: [{ type: "output_text", text: "Remote workspace complete" }], + }, + }, + completed("resp-remote-2"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ error: "unexpected_request" }, { status: 500 }); + }, + }); + + const config = [ + 'model = "gpt-5.6-sol"', + 'model_provider = "ocx_remote_spike"', + 'approval_policy = "never"', + '', + '[model_providers.ocx_remote_spike]', + 'name = "OCX Remote Spike"', + `base_url = "${new URL("/v1", modelServer.url).toString().replace(/\/$/, "")}"`, + 'env_key = "OCX_REMOTE_SPIKE_API_KEY"', + 'wire_api = "responses"', + 'supports_websockets = false', + '', + ].join("\n"); + writeFileSync(join(mainCodexHome, "config.toml"), config, { mode: 0o600 }); + + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "selected-folder", path: executorRoot }], + commandRunner: localIntegrationCommandRunner, + }); + const accountIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const transportSessionId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId: transportSessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: accountIdentity.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: transportSessionId, + expectedDeviceId: deviceId, + accountPublicKey: accountIdentity.publicKey, + devicePrivateKey: deviceIdentity.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + let encryptedTransport: EncryptedRemoteWorkspaceTransport; + let executorEndpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + encryptedTransport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: deviceId, + cipher: handshake.complete(accepted.hello, deviceIdentity.publicKey), + sendCiphertext: value => executorEndpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + executorEndpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, + sessionId: transportSessionId, + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + cipher: accepted.cipher, + executor, + sendCiphertext: value => encryptedTransport.receiveCiphertext(value), + }); + const transport: RemoteWorkspaceTransport = encryptedTransport; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadRef = { id: "" }; + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: () => threadRef.id, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const mcpTokenEnv = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + + const appServer = Bun.spawn([ + codexBin, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(mcpTokenEnv)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=["list_directory","read_file","write_file","exec"]`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { + cwd: coordinatorIsolation, + env: { + PATH: `${sandboxBin}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HOME: mainHome, + CODEX_HOME: mainCodexHome, + OPENCODEX_HOME: mainOcxHome, + OCX_REMOTE_SPIKE_API_KEY: "test-only-not-a-real-key", + [mcpTokenEnv]: bridge.token, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderrPromise = new Response(appServer.stderr).text(); + const peer = new JsonLinePeer(appServer.stdout, appServer.stdin); + + try { + peer.send({ + method: "initialize", + id: 0, + params: { + clientInfo: { name: "ocx_remote_workspace_test", title: "OCX Remote Workspace Test", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + const initialized = await peer.waitFor(message => message.id === 0); + expect(initialized.error).toBeUndefined(); + peer.send({ method: "initialized", params: {} }); + + peer.send({ + method: "thread/start", + id: 1, + params: { + ...remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: mcpTokenEnv, + hubRuntimeReadPaths: [dirname(realpathSync(codexBin)), sandboxBin], + }, + }), + model: "gpt-5.6-sol", + modelProvider: "ocx_remote_spike", + ephemeral: true, + }, + }); + const threadResponse = await peer.waitFor(message => message.id === 1); + expect(threadResponse.error).toBeUndefined(); + const thread = threadResponse.result?.thread as { id?: string } | undefined; + if (!thread?.id) throw new Error("Codex App Server did not return a thread ID"); + threadRef.id = thread.id; + coordinator.register({ + sessionId: transportSessionId, + threadId: thread.id, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + + peer.send({ + method: "turn/start", + id: 2, + params: { + threadId: thread.id, + input: [{ type: "text", text: "Create the marker in the selected remote workspace." }], + approvalPolicy: "never", + }, + }); + + let turnCompleted = false; + for (let count = 0; count < 200 && !turnCompleted; count += 1) { + const message = await peer.next(); + if (message.method === "item/tool/call" && message.id !== undefined) { + const response = await coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + peer.send(response); + } + if (message.method === "turn/completed") turnCompleted = true; + if (message.id === 2 && message.error) throw new Error(`turn/start failed: ${JSON.stringify(message.error)}`); + } + + expect(turnCompleted).toBe(true); + expect(existsSync(join(executorRoot, "integration-marker.txt"))).toBe(true); + expect(readFileSync(join(executorRoot, "integration-marker.txt"), "utf8")).toBe("computer-2"); + expect(readFileSync(join(coordinatorIsolation, "integration-marker.txt"), "utf8")).toBe("main-unchanged"); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain(REMOTE_WORKSPACE_TOOL_NAMESPACE); + // Current Codex consolidates MCP into the sandboxed functions.exec code-mode tool. + // Executing the nested remote helper above proves the registered MCP server is callable. + expect(JSON.stringify((requestBodies[0] as CapturedResponsesRequest).input)).toContain('"name":"functions"'); + const followUp = requestBodies[1] as CapturedResponsesRequest; + const toolOutput = followUp.input?.find(item => item.type === "custom_tool_call_output"); + expect(toolOutput).toBeDefined(); + const serializedToolOutput = JSON.stringify(toolOutput); + expect(serializedToolOutput).toContain("executor-cwd:"); + expect(serializedToolOutput).toContain(executorRoot); + expect(serializedToolOutput).not.toContain(coordinatorIsolation); + expect(serializedToolOutput).not.toContain("HUB-SECRET-MUST-NOT-LEAK"); + } finally { + encryptedTransport.close(); + appServer.kill(); + await appServer.exited; + await stderrPromise; + await modelServer.stop(true); + await bridge.stop(); + removeTreeWithRetry(root); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-claude.integration.test.ts b/tests/clients/remote-workspace-claude.integration.test.ts new file mode 100644 index 0000000000..c29cda1e20 --- /dev/null +++ b/tests/clients/remote-workspace-claude.integration.test.ts @@ -0,0 +1,166 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sse(events: Array<{ event: string; data: unknown }>): Response { + return new Response(events.map(item => `event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream", "cache-control": "no-store" }, + }); +} + +function messageStart(id: string): { event: string; data: unknown } { + return { + event: "message_start", + data: { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model: "claude-test", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 8, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 1 }, + }, + }, + }; +} + +const claudePath = process.env.OCX_CLAUDE_BIN; +const claudeTest = claudePath ? test : test.skip; + +claudeTest("real Claude Code uses only the selected remote executor MCP tools", async () => { + if (!claudePath) return; + const root = mkdtempSync(join(tmpdir(), "ocx-remote-claude-real-")); + roots.push(root); + const workspace = join(root, "executor"); + const home = join(root, "home"); + mkdirSync(workspace); + mkdirSync(home); + writeFileSync(join(workspace, "marker.txt"), "only-on-computer-2"); + const requestBodies: Array> = []; + const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/count_tokens")) return Response.json({ input_tokens: 8 }); + if (!url.pathname.endsWith("/messages")) return Response.json({ error: { message: "not found" } }, { status: 404 }); + const body = await req.json() as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + return sse([ + messageStart("msg_remote_tool"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_remote_read", name: "mcp__ocx_remote_workspace__read_file", input: {} } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"path\":\"marker.txt\"}" } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 8 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + } + return sse([ + messageStart("msg_remote_answer"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Read only-on-computer-2 from the executor." } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 12 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + }, + }); + const deviceId = crypto.randomUUID(); + const executor = new RemoteWorkspaceExecutor({ deviceId, roots: [{ id: "root", path: workspace }] }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: candidate => candidate === deviceId, + invoke: request => executor.invoke(request), + }); + const events: string[] = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [claudePath], + version: "real-smoke", + env: { + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), + ANTHROPIC_BASE_URL: model.url.toString().replace(/\/$/, ""), + ANTHROPIC_AUTH_TOKEN: "test-only-token", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await handle.prompt("Read marker.txt from the remote workspace."); + expect(events.some(event => event.includes("Read only-on-computer-2 from the executor."))).toBe(true); + expect(JSON.stringify(requestBodies.at(-1))).toContain("only-on-computer-2"); + expect(JSON.stringify(requestBodies)).not.toContain("remote_exec"); + const persistedThreadId = handle.threadId; + unregister(); + await handle.stop(); + const resumed = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + resumeThreadId: persistedThreadId, + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregisterResumed = coordinator.register({ + sessionId: "session-1", + threadId: resumed.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await resumed.prompt("Continue the same remote session."); + expect(resumed.threadId).toBe(persistedThreadId); + expect(JSON.stringify(requestBodies.at(-1))).toContain("Continue the same remote session."); + } finally { + unregisterResumed(); + await resumed.stop(); + } + } finally { + unregister(); + await handle.stop(); + await model.stop(true); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-cli-runtimes.test.ts b/tests/clients/remote-workspace-cli-runtimes.test.ts new file mode 100644 index 0000000000..33785b64e7 --- /dev/null +++ b/tests/clients/remote-workspace-cli-runtimes.test.ts @@ -0,0 +1,67 @@ +import { fixturePath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + PiRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, +} from "../../src/remote-control"; + +function coordinator(): RemoteWorkspaceCoordinator { + return new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }); +} + +test("Claude runtime keeps the CLI on the Hub and emits its answer", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, fixturePath("fake-claude-stream.ts")], + version: "test", + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: (type, text) => events.push({ type, text }), + }); + try { + await handle.prompt("hello remote"); + expect(events).toEqual([{ type: "assistant", text: "Hub answer: hello remote" }]); + } finally { + await handle.stop(); + } +}); + +const piPath = process.env.OCX_PI_BIN; +const piTest = piPath ? test : test.skip; + +piTest("real Pi RPC starts with only the explicit Remote Workspace extension", async () => { + if (!piPath) return; + const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [piPath], version: "test" }); + const startOptions = { + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: () => {}, + } as const; + const handle = await factory.start(startOptions); + expect(handle.threadId).toMatch(/^[0-9a-f-]{36}$/); + const threadId = handle.threadId; + await handle.stop(); + const resumed = await factory.start({ ...startOptions, resumeThreadId: threadId }); + expect(resumed.threadId).toBe(threadId); + await resumed.stop(); +}); diff --git a/tests/clients/remote-workspace-cli.test.ts b/tests/clients/remote-workspace-cli.test.ts new file mode 100644 index 0000000000..2358d59133 --- /dev/null +++ b/tests/clients/remote-workspace-cli.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; +import { runRemoteWorkspaceCommand } from "../../src/cli/remote-workspace"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceDeviceStateStore { + constructor(public state: RemoteWorkspaceDeviceState | null = null) {} + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +function state(): RemoteWorkspaceDeviceState { + return { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "linux-x64", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: "/work/project" }], + toolchainRoots: [], + }; +} + +describe("ocx remote-workspace", () => { + test("reads the one-time pairing code from stdin and never requires it in argv", async () => { + const store = new MemoryStore(); + const expected = state(); + let received: Record | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand([ + "pair", + "https://hub.example.test", + "--root", "/work/project", + "--root", "/work/other", + "--executor-helper", "/opt/opencodex/remote-workspace-helper", + "--name", "Computer 2", + "--pairing-code-stdin", + "--json", + ], { + store, + stdinImpl: Readable.from(["ABCD-EFGH-JKLM\n"]), + pair: async options => { + received = options as unknown as Record; + return expected; + }, + }); + expect(code).toBe(0); + expect(received).toMatchObject({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + name: "Computer 2", + roots: [{ path: "/work/project" }, { path: "/work/other" }], + nativeHelperPath: "/opt/opencodex/remote-workspace-helper", + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceToken); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("status reports local executor identity without secret material", async () => { + const saved = state(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runRemoteWorkspaceCommand(["status", "--json"], { store: new MemoryStore(saved) })).toBe(0); + const output = JSON.stringify(log.mock.calls); + expect(output).toContain("Computer 2"); + expect(output).toContain("/work/project"); + expect(output).not.toContain(saved.deviceToken); + expect(output).not.toContain(saved.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("agent hands the paired state to the reconnecting runner", async () => { + const saved = state(); + const controller = new AbortController(); + let received: RemoteWorkspaceDeviceState | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand(["agent"], { + store: new MemoryStore(saved), + signal: controller.signal, + runAgent: async options => { received = options.state; }, + }); + expect(code).toBe(0); + expect(received?.deviceId).toBe(saved.deviceId); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts new file mode 100644 index 0000000000..b9d259c2eb --- /dev/null +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -0,0 +1,120 @@ +import { repoPath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + CodexRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +test("Codex Remote Workspace runtime owns the model process on the Hub", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const transport: RemoteWorkspaceTransport = { + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + version: "0.146.0-test", + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + notifications: [{ + method: "item/completed", + params: { item: { id: "answer-1", type: "agentMessage", text: "Done from Computer 1" } }, + }], + }], + }), + }, + }); + + expect(await factory.available()).toEqual({ available: true, version: "0.146.0-test" }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: (type, text) => events.push({ type, text }), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + await handle.prompt("Inspect the remote project"); + expect(events).toContainEqual({ type: "assistant", text: "Done from Computer 1" }); + } finally { + unregister(); + await handle.stop(); + } +}); + +test("Codex Remote Workspace stop interrupts a held turn", async () => { + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{ heldUntilInterrupt: true }] }) }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: () => {}, + }); + coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const turn = handle.prompt("Hold this turn").then(() => "resolved", () => "rejected"); + await new Promise(resolvePromise => setTimeout(resolvePromise, 30)); + await handle.stop(); + expect(await turn).toBe("rejected"); +}); + +test("Codex Remote Workspace resumes the persisted App Server thread ID", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const handle = await factory.start({ + sessionId: "session-resume", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + resumeThreadId: "thread-persisted", + coordinator, + emit: () => {}, + }); + expect(handle.threadId).toBe("thread-persisted"); + await handle.stop(); +}); diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts new file mode 100644 index 0000000000..9595b2b4f3 --- /dev/null +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + RemoteWorkspaceExecutor, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandArgv, + linuxRemoteWorkspaceCommandRunnerAvailable, + nativeRemoteWorkspaceCommandRunnerAvailable, + pinRemoteWorkspaceNativeHelper, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-bwrap-")); + roots.push(root); + const workspace = join(root, "workspace"); + const outside = join(root, "outside-secret.txt"); + mkdirSync(join(workspace, "project"), { recursive: true }); + writeFileSync(outside, "must-not-be-visible"); + return { root, workspace, outside }; +} + +function fakeNativeHelper(root: string, response: Record, requestPath?: string) { + const path = join(root, "ocx-remote-helper-test"); + const encodedResponse = JSON.stringify(response).replaceAll("'", "'\\''"); + const requestCapture = requestPath + ? `input=$(cat); printf '%s' "$input" > '${requestPath.replaceAll("'", "'\\''")}'` + : "cat >/dev/null"; + writeFileSync(path, `#!/bin/sh\nset -eu\n${requestCapture}\nprintf '%s\\n' '${encodedResponse}'\n`, { mode: 0o700 }); + chmodSync(path, 0o700); + return pinRemoteWorkspaceNativeHelper(path); +} + +describe("remote workspace Linux command sandbox", () => { + test("rejects a sandbox executable inside a writable workspace before probing", () => { + const state = fixture(); + const path = join(state.workspace, "bwrap"); + writeFileSync(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + let probes = 0; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: path, writableRoots: [state.workspace], + probe() { probes += 1; return true; }, + })).toBe(false); + expect(probes).toBe(0); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], root: state.workspace, cwd: state.workspace, + timeoutMs: 1_000, maxOutputBytes: 4096, + }, { bubblewrapPath: path })).toThrow("outside every writable"); + }); + + test("Windows command capability stays unavailable even with a positive probe seam", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probes = 0; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + platform: "win32", helper, + writableRoots: [state.workspace], probe() { probes += 1; return { version: 1, ok: true, probe: true }; }, + })).toBe(false); + expect(probes).toBe(0); + }); + + test("builds a minimal bubblewrap argv with one writable workspace", () => { + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["/bin/sh", "-lc", "pwd"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { bubblewrapPath: process.execPath }); + expect(argv[0]).toBe(process.execPath); + expect(argv).toContain("--unshare-net"); + expect(argv).toContain("--clearenv"); + expect(argv).toContain("--bind"); + expect(argv).toContain(state.workspace); + expect(argv).toContain("/workspace/project"); + expect(argv).not.toContain(state.outside); + }); + + test("runs inside the selected root and cannot see an adjacent host file", async () => { + if (!linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "root", path: state.workspace }], + commandRunner: createLinuxRemoteWorkspaceCommandRunner(), + }); + const result = await executor.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: deviceId, + rootId: "root", + tool: "exec", + arguments: { + command: [ + "/bin/sh", + "-lc", + `test ! -e ${JSON.stringify(state.outside)} && printf sandboxed > marker.txt && pwd`, + ], + cwd: "project", + timeoutMs: 5_000, + }, + }); + expect(result.ok).toBe(true); + expect(result.value).toMatchObject({ exitCode: 0, cwd: "project" }); + expect(JSON.stringify(result.value)).toContain("/workspace/project"); + expect(readFileSync(join(state.workspace, "project", "marker.txt"), "utf8")).toBe("sandboxed"); + }); + + test("keeps exec disabled where an equivalent platform sandbox is unavailable", () => { + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "win32" })).toBeUndefined(); + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "darwin" })).toBeUndefined(); + }); + + test("advertises native exec only after a digest-pinned confinement probe", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probeRequest: unknown; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe(request) { + probeRequest = request; + return { version: 1, ok: true, probe: true }; + }, + })).toBe(true); + expect(probeRequest).toEqual({ version: 1, operation: "probe" }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "win32", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: false, error: "not confined" }), + }, + })).toBeUndefined(); + writeFileSync(helper.path, "replaced", { mode: 0o700 }); + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + })).toBe(false); + }); + + test("sends native command authority over bounded stdin and decodes one strict result", async () => { + const state = fixture(); + const requestPath = join(state.root, "request.json"); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 7, + stdoutBase64: Buffer.from("native stdout").toString("base64"), + stderrBase64: Buffer.from("native stderr").toString("base64"), + }, requestPath); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + const result = await runner.run({ + command: ["/usr/bin/printf", "hello world"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result).toEqual({ exitCode: 7, stdout: "native stdout", stderr: "native stderr" }); + const request = JSON.parse(readFileSync(requestPath, "utf8")); + expect(request).toEqual({ + version: 1, + operation: "run", + root: state.workspace, + cwd: join(state.workspace, "project"), + command: ["/usr/bin/printf", "hello world"], + toolchainRoots: [], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + networkAccess: false, + }); + expect(JSON.stringify(request)).not.toContain(process.env.OPENAI_API_KEY ?? "__no_api_key__"); + }); + + test("rejects widened or malformed native helper responses", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "@@not-base64@@", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["cmd.exe"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("invalid stdout"); + }); + + test("never advertises or invokes a native helper from inside a writable workspace", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.workspace, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "darwin", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }, + })).toBeUndefined(); + + }); + + test("binds every native command to the runner's construction-time writable roots", async () => { + const state = fixture(); + const other = join(state.root, "other-workspace"); + mkdirSync(other); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["/usr/bin/true"], + root: other, + cwd: other, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("outside the native runner grant"); + }); + + test("revalidates approved toolchain roots and rejects a later symlink substitution", () => { + const state = fixture(); + const realToolchain = join(state.root, "real-toolchain"); + const substituted = join(state.root, "toolchain"); + mkdirSync(realToolchain); + symlinkSync(realToolchain, substituted, process.platform === "win32" ? "junction" : "dir"); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { + bubblewrapPath: process.execPath, + toolchainRoots: [substituted], + })).toThrow("remain a real directory"); + }); + + test("rejects a pre-existing hardlink before starting a workspace command", async () => { + const state = fixture(); + linkSync(state.outside, join(state.workspace, "outside-alias")); + const runner = createLinuxRemoteWorkspaceCommandRunner({ + bubblewrapPath: process.execPath, + spawn: (() => { throw new Error("sandbox spawn must not be reached"); }) as typeof Bun.spawn, + }); + await expect(runner.run({ + command: ["/bin/true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("hard-linked file"); + expect(readFileSync(state.outside, "utf8")).toBe("must-not-be-visible"); + }); + + test("the production Linux runner exposes only the current OCX Bun file, not its host directory", async () => { + if (process.platform !== "linux" || !linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, { runtimeExecutablePath: process.execPath }); + expect(argv).toContain("/ocx-runtime/bin/bun"); + expect(argv).toContain(realpathSync(process.execPath)); + expect(argv).not.toContain(dirname(realpathSync(process.execPath))); + expect(argv).not.toContain(process.env.HOME ?? "__missing_home__"); + const runner = createPlatformRemoteWorkspaceCommandRunner(); + if (!runner) throw new Error("Linux Remote Workspace runner was not detected"); + const result = await runner.run({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(Bun.version); + }); +}); diff --git a/tests/clients/remote-workspace-device.test.ts b/tests/clients/remote-workspace-device.test.ts new file mode 100644 index 0000000000..386f2ddfbd --- /dev/null +++ b/tests/clients/remote-workspace-device.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceHub, + connectRemoteWorkspaceAgent, + generateRemoteControlIdentityKeyPair, + pairRemoteWorkspaceDevice, + parseRemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +class HubStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceHubState) { this.state = structuredClone(state); } +} + +class DeviceStore implements RemoteWorkspaceDeviceStateStore { + state: RemoteWorkspaceDeviceState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +describe("remote workspace device enrollment", () => { + test("pairs through one HTTPS request while keeping the real root path on Computer 2", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-")); + roots.push(root); + const workspace = join(root, "private-project"); + const toolchain = join(root, "private-toolchain"); + const nativeHelper = join(root, "private-native-helper"); + mkdirSync(workspace); + mkdirSync(toolchain); + writeFileSync(nativeHelper, "test helper", { mode: 0o700 }); + chmodSync(nativeHelper, 0o700); + const hubStore = new HubStore(); + const hub = new RemoteWorkspaceHub(hubStore); + const grant = hub.createPairingGrant(); + const deviceStore = new DeviceStore(); + let requestBody = ""; + const state = await pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: grant.code, + name: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ path: workspace, label: "Main project" }], + toolchainRoots: [toolchain], + nativeHelperPath: nativeHelper, + store: deviceStore, + fetchImpl: async (input, init) => { + expect(String(input)).toBe("https://hub.example.test/remote-workspace/pair"); + requestBody = String(init?.body); + const paired = hub.pairDevice(JSON.parse(requestBody)); + return Response.json(paired, { status: 201 }); + }, + }); + expect(requestBody).not.toContain(workspace); + expect(requestBody).not.toContain(toolchain); + expect(requestBody).not.toContain(nativeHelper); + expect(requestBody).not.toContain(state.deviceIdentity.privateKey); + expect(state).toMatchObject({ + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceName: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ label: "Main project", path: realpathSync(workspace) }], + toolchainRoots: [realpathSync(toolchain)], + }); + expect(state.nativeHelper?.path).toBe(realpathSync(nativeHelper)); + expect(state.nativeHelper?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(deviceStore.state).toEqual(state); + expect(hub.authenticateDeviceToken(state.deviceToken)?.id).toBe(state.deviceId); + expect(JSON.stringify(hubStore.state)).not.toContain(state.deviceToken); + expect(JSON.stringify(hubStore.state)).not.toContain(workspace); + }); + + test("requires HTTPS except for explicit loopback development", async () => { + expect(() => parseRemoteWorkspaceDeviceState({ version: 1, hubUrl: "http://example.test" })) + .toThrow("must use HTTPS"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-local-")); + roots.push(root); + const store = new DeviceStore(); + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "http://127.0.0.1:7075", + pairingCode: "AAAA-BBBB-CCCC", + roots: [{ path: root }], + store, + fetchImpl: async () => Response.json({ error: "invalid or expired" }, { status: 401 }), + })).rejects.toThrow("invalid or expired"); + expect(store.state).toBeNull(); + }); + + test("cancels a chunked Hub response before it can grow beyond the pairing limit", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-bounded-response-")); + roots.push(root); + const store = new DeviceStore(); + let cancelled = false; + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + roots: [{ path: root }], + store, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }), { status: 200 }), + })).rejects.toThrow("response is too large"); + expect(cancelled).toBe(true); + expect(store.state).toBeNull(); + }); + + test("stops cleanly even when the platform WebSocket rejects close while connecting", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-stop-")); + roots.push(root); + const state: RemoteWorkspaceDeviceState = { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "darwin-arm64", + capabilities: ["workspace.read", "workspace.write"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], + toolchainRoots: [], + }; + const handle = connectRemoteWorkspaceAgent({ + state, + commandRunner: null, + webSocketFactory: () => ({ + readyState: 0, + send() {}, + close() { throw new Error("CONNECTING close is not supported"); }, + addEventListener() {}, + }), + }); + handle.stop(); + await expect(handle.connected).rejects.toThrow("stopped"); + await handle.closed; + }); +}); diff --git a/tests/clients/remote-workspace-hub.test.ts b/tests/clients/remote-workspace-hub.test.ts new file mode 100644 index 0000000000..b6ce600238 --- /dev/null +++ b/tests/clients/remote-workspace-hub.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubAgentConnection, + RemoteWorkspacePairingRateLimitError, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceHubState, + serializeRemoteWorkspaceAgentMessage, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + writes = 0; + + load(): RemoteWorkspaceHubState | null { + return this.state ? structuredClone(this.state) : null; + } + + save(state: RemoteWorkspaceHubState): void { + this.state = structuredClone(state); + this.writes += 1; + } +} + +function pairedHub(now = Date.parse("2026-09-03T12:00:00.000Z")) { + const store = new MemoryStore(); + const hub = new RemoteWorkspaceHub(store, () => now); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + const paired = hub.pairDevice({ + code: grant.code.replaceAll("-", " ").toLowerCase(), + name: "Computer 2", + platform: "linux-x64", + publicKey: deviceIdentity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }); + return { hub, store, deviceIdentity, paired, now }; +} + +describe("remote workspace hub registry", () => { + test("pairs one named OCX-only device without persisting its bearer token", () => { + const state = pairedHub(); + expect(state.paired.device).toMatchObject({ + name: "Computer 2", + platform: "linux-x64", + online: false, + roots: [{ label: "Project" }], + }); + expect(state.paired.deviceToken).toStartWith("ocxrw_"); + expect(state.paired.hubPublicKey).toBe(state.hub.identity().publicKey); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)?.id).toBe(state.paired.device.id); + expect(JSON.stringify(state.store.state)).not.toContain(state.paired.deviceToken); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("publicKey"); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("tokenHash"); + }); + + test("consumes pairing codes once and enforces unique device names", () => { + const state = pairedHub(); + expect(() => state.hub.pairDevice({ + code: "not-a-code", + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + })).toThrow("invalid or expired"); + + const grant = state.hub.createPairingGrant(); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "computer 2", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("already in use"); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "Computer 3", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("invalid or expired"); + }); + + test("bounds invalid pairing attempts by hashed source, expiry, and map capacity", () => { + let now = Date.parse("2026-09-03T12:00:00.000Z"); + const hub = new RemoteWorkspaceHub(new MemoryStore(), () => now); + const invalid = (source: string) => hub.pairDevice({ code: "AAAA-BBBB-CCCC" }, source); + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.10")).toThrow("invalid or expired"); + } + let limited: unknown; + try { invalid("peer:192.0.2.10"); } catch (error) { limited = error; } + expect(limited).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(limited).toMatchObject({ reason: "source", retryAfterSeconds: 600 }); + + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + } + const identity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: grant.code, + name: "Computer 2", + platform: "linux-x64", + publicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }, "peer:192.0.2.11").device.name).toBe("Computer 2"); + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + + now += 10 * 60_000 + 1; + const afterExpiry = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: afterExpiry.code, + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + }, "peer:192.0.2.10").device.name).toBe("Computer 3"); + + const capped = new RemoteWorkspaceHub(new MemoryStore(), () => now); + for (let source = 0; source < 1_024; source += 1) { + expect(() => capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, `peer:${source}`)) + .toThrow("invalid or expired"); + } + let capacity: unknown; + try { capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, "peer:overflow"); } + catch (error) { capacity = error; } + expect(capacity).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(capacity).toMatchObject({ reason: "capacity", retryAfterSeconds: 1 }); + }); + + test("tracks online presence, replaces reconnects, and revokes the device", () => { + const state = pairedHub(); + const closes: string[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + socket: { + send: () => {}, + close: (_code, reason) => closes.push(reason), + }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + expect(state.hub.listDevices()[0]).toMatchObject({ online: false }); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(state.hub.listDevices()[0]).toMatchObject({ online: true, lastSeenAt: "2026-09-03T12:00:00.000Z" }); + expect(state.hub.connection(state.paired.device.id)).toBe(connection); + expect(state.hub.revokeDevice(state.paired.device.id)).toBe(true); + expect(state.hub.listDevices()).toEqual([]); + expect(connection.isOnline()).toBe(false); + expect(state.store.state?.devices).toEqual([]); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)).toBeNull(); + expect(closes).toEqual(["remote workspace device was revoked"]); + }); + + test("refuses mismatched persisted hub identity keys", () => { + const first = generateRemoteControlIdentityKeyPair(); + const second = generateRemoteControlIdentityKeyPair(); + expect(() => parseRemoteWorkspaceHubState({ + version: 1, + identity: { publicKey: first.publicKey, privateKey: second.privateKey }, + devices: [], + })).toThrow("does not match"); + }); +}); + +test("presence reduces availability without changing the durable enrollment grant", () => { + const state = pairedHub(); + const advertised: unknown[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + onCapabilities: capabilities => state.hub.updateDeviceCapabilities(state.paired.device.id, capabilities), + socket: { send: value => { advertised.push(JSON.parse(value)); }, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read"], + })); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(advertised[0]).toMatchObject({ capabilities: ["workspace.read"] }); + state.hub.detachConnection(state.paired.device.id, connection); + + const reconnect = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + socket: { send() {}, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, reconnect); + reconnect.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + })); + expect(reconnect.capabilities()).toEqual(["workspace.read", "workspace.write"]); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + state.hub.closeAllConnections(); +}); diff --git a/tests/clients/remote-workspace-linux-confinement.test.ts b/tests/clients/remote-workspace-linux-confinement.test.ts new file mode 100644 index 0000000000..4d88d29096 --- /dev/null +++ b/tests/clients/remote-workspace-linux-confinement.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +test("hosted Linux proves workspace write and denies adjacent access, loopback, and detached survival", async () => { + const required = process.env.OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT === "1"; + const available = process.platform === "linux" + && existsSync("/usr/bin/bwrap") + && linuxRemoteWorkspaceCommandRunnerAvailable(); + if (!required && !available) return; + expect(process.platform).toBe("linux"); + expect(existsSync("/usr/bin/bwrap")).toBe(true); + expect(available).toBe(true); + + const parent = mkdtempSync(join(tmpdir(), "ocx-remote-linux-confinement-")); + roots.push(parent); + const workspace = join(parent, "workspace"); + const marker = join(workspace, "probe-marker"); + const outsideRead = join(parent, "outside-secret"); + const outsideWrite = join(parent, "outside-write"); + mkdirSync(workspace); + writeFileSync(join(workspace, ".keep"), "workspace"); + writeFileSync(outsideRead, "must-not-be-visible"); + + let acceptedConnections = 0; + const listener = createServer(socket => { + acceptedConnections += 1; + socket.destroy(); + }); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("loopback probe did not bind TCP"); + const runner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [workspace] }, + }); + if (!runner) throw new Error("production Linux Remote Workspace runner was not created"); + + try { + const result = await runner.run({ + root: workspace, + cwd: workspace, + command: [ + "bun", + "-e", + [ + 'import { readFileSync, writeFileSync } from "node:fs";', + 'const [outsideRead, outsideWrite, port] = process.argv.slice(1);', + 'if (!outsideRead || !outsideWrite || !port) process.exit(31);', + 'if (process.execPath !== "/ocx-runtime/bin/bun") process.exit(29);', + 'writeFileSync("probe-marker", "sandboxed");', + 'try { readFileSync(outsideRead); process.exit(26); } catch (error) { void error; }', + 'try { writeFileSync(outsideWrite, "escaped"); process.exit(27); } catch (error) { void error; }', + 'try { await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); process.exit(28); } catch (error) { void error; }', + ].join("\n"), + "--", + outsideRead, + outsideWrite, + String(address.port), + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("sandboxed"); + expect(existsSync(outsideWrite)).toBe(false); + expect(acceptedConnections).toBe(0); + } finally { + await new Promise(resolve => listener.close(() => resolve())); + } + + const lateMarker = join(workspace, "late-marker"); + const controller = new AbortController(); + const pending = runner.run({ + root: workspace, + cwd: workspace, + command: [ + "/bin/bash", + "-c", + "setsid /bin/bash -c 'sleep 0.5; printf escaped > late-marker' >/dev/null 2>&1 & sleep 30", + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + signal: controller.signal, + }); + await Bun.sleep(100); + controller.abort(); + await expect(pending).rejects.toThrow("cancelled"); + await Bun.sleep(750); + expect(existsSync(lateMarker)).toBe(false); + + const unsafeWorkspace = join(parent, "unsafe-workspace"); + mkdirSync(unsafeWorkspace); + linkSync(outsideRead, join(unsafeWorkspace, "outside-alias")); + expect(createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [unsafeWorkspace] }, + })).toBeUndefined(); + expect(readFileSync(outsideRead, "utf8")).toBe("must-not-be-visible"); +}); diff --git a/tests/clients/remote-workspace-platform.test.ts b/tests/clients/remote-workspace-platform.test.ts new file mode 100644 index 0000000000..ec48362055 --- /dev/null +++ b/tests/clients/remote-workspace-platform.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + findExecutableOnPath, +} from "../../src/remote-control/workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + remoteWorkspaceThreadStartParams, + linuxRemoteWorkspaceCommandRunnerAvailable, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + truncateRemoteWorkspaceUtf8, + validateRemoteWorkspaceRelativePath, +} from "../../src/remote-control"; + +describe("Remote Workspace cross-platform boundaries", () => { + test("resolves Windows PATH and PATHEXT with Windows grammar on every test host", () => { + const visited: string[] = []; + const resolved = findExecutableOnPath("claude", { + platform: "win32", + path: "C:\\first;D:\\npm", + pathExt: ".PS1;.EXE;.CMD", + probe(candidate) { + visited.push(candidate); + return candidate.toLowerCase() === "d:\\npm\\claude.cmd"; + }, + }); + expect(resolved).toBe("D:\\npm\\claude.cmd"); + expect(visited).toEqual([ + "C:\\first\\claude.exe", + "C:\\first\\claude.cmd", + "D:\\npm\\claude.exe", + "D:\\npm\\claude.cmd", + ]); + }); + + test("launches Windows npm shims through escaped ComSpec and leaves Unix argv direct", () => { + const windows = remoteWorkspaceProcessInvocation( + ["C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd", "--system-prompt", "a&b"], + { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } }, + ); + expect(windows.file).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(windows.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(windows.args[3]).toContain("a^&b"); + expect(windows.options.windowsVerbatimArguments).toBe(true); + + expect(remoteWorkspaceProcessInvocation(["/usr/bin/claude", "--version"], { platform: "linux" })) + .toEqual({ file: "/usr/bin/claude", args: ["--version"], options: {} }); + expect(remoteWorkspaceProcessInvocation(["/opt/homebrew/bin/pi", "--version"], { platform: "darwin" })) + .toEqual({ file: "/opt/homebrew/bin/pi", args: ["--version"], options: {} }); + }); + + test("stops the exact Windows wrapper tree through trusted taskkill semantics", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const calls: Array<{ file: string; args: readonly string[] }> = []; + let fallbackKills = 0; + await stopRemoteWorkspaceProcess({ + pid: 4242, + exitCode: null, + exited, + kill() { fallbackKills += 1; settle(0); }, + }, { + platform: "win32", + taskkillPath: "C:\\Windows\\System32\\taskkill.exe", + execFile(file, args) { calls.push({ file, args }); settle(0); }, + waitMs: 10, + }); + expect(calls).toEqual([{ + file: "C:\\Windows\\System32\\taskkill.exe", + args: ["/PID", "4242", "/T", "/F"], + }]); + expect(fallbackKills).toBe(0); + }); + + test("escalates a Unix child that ignores SIGTERM without killing unrelated processes", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const signals: Array = []; + await stopRemoteWorkspaceProcess({ + pid: 4243, + exitCode: null, + exited, + kill(signal) { + signals.push(signal); + if (signal === "SIGKILL") settle(137); + }, + }, { platform: "darwin", waitMs: 1 }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + test("runs every cleanup owner even when an earlier resource fails", async () => { + const completed: string[] = []; + await expect(runRemoteWorkspaceCleanupSteps([ + () => { completed.push("process"); throw new Error("process cleanup failed"); }, + async () => { completed.push("bridge"); }, + () => { completed.push("isolation"); }, + ])).rejects.toThrow("process cleanup failed"); + expect(completed).toEqual(["process", "bridge", "isolation"]); + }); + + test("reports an owned child that remains alive after forced termination", async () => { + const exited = new Promise(() => {}); + await expect(stopRemoteWorkspaceProcess({ + pid: 4244, + exitCode: null, + exited, + kill() {}, + }, { platform: "linux", waitMs: 1 })).rejects.toThrow("did not exit after SIGKILL"); + }); + + test("reconnection cannot widen the capability grant recorded at pairing", () => { + const runner = { async run() { return { exitCode: 0, stdout: "", stderr: "" }; } }; + expect(remoteWorkspaceCapabilitiesForCommandRunner(runner, ["workspace.read"])) + .toEqual(["workspace.read"]); + expect(remoteWorkspaceCapabilitiesForCommandRunner(undefined, [ + "workspace.read", "workspace.write", "workspace.exec", + ])).toEqual(["workspace.read", "workspace.write"]); + }); + + test("bounds large UTF-8 text without quadratic trimming or split surrogate pairs", () => { + const value = `${"가".repeat(100_000)}😀tail`; + const truncated = truncateRemoteWorkspaceUtf8(value, 8_192); + expect(Buffer.byteLength(truncated, "utf8")).toBeLessThanOrEqual(8_192); + expect(truncated.endsWith("\ud83d")).toBe(false); + expect(truncated.includes("tail")).toBe(false); + }); + + test("uses platform-native deny-local shell environments", () => { + const windows = remoteWorkspaceThreadStartParams({ + executorName: "Windows executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "win32", + windowsSystemDirectory: "C:\\Windows\\System32", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(windows.config.shell_environment_policy.set).toMatchObject({ + USERPROFILE: "/test/coordinator", + TEMP: "/test/coordinator", + PATH: "C:\\Windows\\System32", + }); + expect(windows.config.shell_environment_policy.set.PATH).not.toContain("/usr/"); + + const mac = remoteWorkspaceThreadStartParams({ + executorName: "Mac executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "darwin", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(mac.config.shell_environment_policy.set.PATH).toBe("/usr/bin:/bin"); + }); + + test("advertises Linux exec only after the namespace probe succeeds", () => { + if (!existsSync("/usr/bin/bwrap")) return; + let sawNetworkIsolation = false; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe(argv) { + sawNetworkIsolation = argv.includes("--unshare-net"); + return false; + }, + })).toBe(false); + expect(sawNetworkIsolation).toBe(true); + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe: () => true, + })).toBe(true); + }); + + test("rejects Windows device names, ADS, and normalized aliases without blocking POSIX names", () => { + for (const path of ["NUL", "con.txt", "CONIN$", "CLOCK$.txt", "logs\\COM1.json", "file.txt:token", "name.", "name ", "bad\u0001name"]) { + expect(() => validateRemoteWorkspaceRelativePath(path, undefined, "win32")).toThrow("safe Windows"); + } + expect(validateRemoteWorkspaceRelativePath("normal\\file.txt", undefined, "win32")) + .toBe("normal\\file.txt"); + expect(validateRemoteWorkspaceRelativePath("NUL:valid-on-posix", undefined, "linux")) + .toBe("NUL:valid-on-posix"); + }); +}); diff --git a/tests/clients/remote-workspace-secret-store.test.ts b/tests/clients/remote-workspace-secret-store.test.ts new file mode 100644 index 0000000000..6913e11bf6 --- /dev/null +++ b/tests/clients/remote-workspace-secret-store.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateRemoteControlIdentityKeyPair } from "../../src/remote-control/crypto"; +import { RemoteWorkspaceHubFileStore } from "../../src/remote-control/workspace-hub"; +import { RemoteWorkspaceDeviceFileStore } from "../../src/remote-control/workspace-device"; +import { RemoteWorkspaceSessionFileStore } from "../../src/remote-control/workspace-sessions"; +import { workspaceSecretPermissions, type WorkspaceSecretPermissions } from "../../src/remote-control/workspace-secret-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const roots: string[] = []; +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixtures() { + const root = mkdtempSync(join(tmpdir(), "ocx-workspace-secret-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + const identity = generateRemoteControlIdentityKeyPair(); + const hubState = { version: 1 as const, identity, devices: [] }; + const sessionState = { version: 1 as const, sessions: [] }; + const deviceState = { + version: 1 as const, hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), deviceName: "Executor", devicePlatform: "test", + capabilities: ["workspace.read" as const], deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: identity, hubPublicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], toolchainRoots: [], + }; + return [ + { path: join(root, "hub.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceHubFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(hubState) }; + } }, + { path: join(root, "device.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceDeviceFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(deviceState) }; + } }, + { path: join(root, "sessions.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceSessionFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(sessionState) }; + } }, + ]; +} + +test("all workspace stores distinguish absent state from permission failure", () => { + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + expect(store.load()).toBeNull(); + store.save(); + expect(store.load()).not.toBeNull(); + if (process.platform !== "win32") expect(statSync(fixture.path).mode & 0o777).toBe(0o600); + } +}); + +test("all stores propagate hardening failures before decoding or publishing secret bytes", () => { + for (const fixture of fixtures()) { + for (const failedStep of ["prepareDirectory", "hardenFile"] as const) { + // Invalid JSON would fail if read reached decoding instead of the permission boundary. + writeFileSync(fixture.path, "private-sentinel-not-json", { mode: 0o600 }); + const calls: string[] = []; + const permissions: WorkspaceSecretPermissions = { + prepareDirectory() { calls.push("directory"); if (failedStep === "prepareDirectory") throw new Error("denied hardening"); }, + hardenFile() { calls.push("file"); throw new Error("denied hardening"); }, + }; + const store = fixture.create(fixture.path, permissions); + expect(() => store.load()).toThrow("denied hardening"); + expect(() => store.save()).toThrow("denied hardening"); + expect(readFileSync(fixture.path, "utf8")).toBe("private-sentinel-not-json"); + expect(calls).toEqual(failedStep === "prepareDirectory" + ? ["directory", "directory"] : ["directory", "file", "directory", "file"]); + } + } +}); + +test("secret files refuse symbolic-link targets", () => { + if (process.platform === "win32") return; // Windows link creation requires separate privileges. + const fixture = fixtures()[0]!; + const target = `${fixture.path}.target`; + writeFileSync(target, "private", { mode: 0o600 }); + symlinkSync(target, fixture.path); + expect(() => workspaceSecretPermissions.hardenFile(fixture.path)).toThrow("regular file"); + expect(readFileSync(target, "utf8")).toBe("private"); +}); + + +test("an inaccessible existing store is never reported as first-run absence", () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + store.save(); + const before = readFileSync(fixture.path, "utf8"); + const directory = fixture.path.slice(0, fixture.path.lastIndexOf("/")); + chmodSync(directory, 0); + try { expect(() => store.load()).toThrow(); } + finally { chmodSync(directory, 0o700); } + expect(readFileSync(fixture.path, "utf8")).toBe(before); + } +}); diff --git a/tests/clients/remote-workspace-session-binding.test.ts b/tests/clients/remote-workspace-session-binding.test.ts new file mode 100644 index 0000000000..ac1ab7c7c3 --- /dev/null +++ b/tests/clients/remote-workspace-session-binding.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, + frameRemoteWorkspaceRpcMessage, + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceExecutionRequest, +} from "../../src/remote-control"; + +function fixture() { + const hub = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const sessionId = randomUUID(); + const deviceId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId, deviceId, commandProfile: "codex", capabilities: ["workspace.read"], + accountPrivateKey: hub.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: sessionId, expectedDeviceId: deviceId, + accountPublicKey: hub.publicKey, devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const client = handshake.complete(accepted.hello, device.publicKey); + const invocations: RemoteWorkspaceExecutionRequest[] = []; + const endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, sessionId, rootId: "first-approved-root", + capabilities: ["workspace.read"], cipher: accepted.cipher, + executor: { async invoke(request) { invocations.push(request); return { ok: true }; } }, + sendCiphertext() {}, + }); + const request: RemoteWorkspaceExecutionRequest = { + requestId: randomUUID(), sessionId, executorDeviceId: deviceId, + rootId: "first-approved-root", tool: "read_file", arguments: { path: "marker" }, + }; + return { + invocations, + async send(overrides: Partial = {}) { + const message = new TextEncoder().encode(JSON.stringify({ + version: 1, kind: "request", request: { ...request, ...overrides }, + })); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await endpoint.receiveCiphertext(client.encrypt(frame)); + } + }, + close() { endpoint.close(); client.destroy(); }, + }; +} + +test("encrypted requests cannot leave their session grant before executor invocation", async () => { + const mismatches: Partial[] = [ + { sessionId: randomUUID() }, + { executorDeviceId: randomUUID() }, + { rootId: "second-approved-root" }, + { tool: "write_file", arguments: { path: "marker", content: "changed", expectedSha256: null } }, + ]; + for (const mismatch of mismatches) { + const state = fixture(); + try { + await expect(state.send(mismatch)).rejects.toThrow(); + expect(state.invocations).toEqual([]); + } finally { state.close(); } + } +}); + +test("a matching encrypted read reaches the selected executor once", async () => { + const state = fixture(); + try { + await state.send(); + expect(state.invocations).toHaveLength(1); + expect(state.invocations[0]).toMatchObject({ rootId: "first-approved-root", tool: "read_file" }); + } finally { state.close(); } +}); diff --git a/tests/clients/remote-workspace-sessions.test.ts b/tests/clients/remote-workspace-sessions.test.ts new file mode 100644 index 0000000000..dc0be8ed16 --- /dev/null +++ b/tests/clients/remote-workspace-sessions.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import type { RemoteWorkspaceHub } from "../../src/remote-control/workspace-hub"; +import { + RemoteWorkspaceSessionService, + type RemoteWorkspaceRuntimeFactory, + type RemoteWorkspaceRuntimeHandle, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceSessionState, + type RemoteWorkspaceSessionStateStore, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +const DEVICE_ID = "11111111-1111-4111-8111-111111111111"; +const ROOT_ID = "22222222-2222-4222-8222-222222222222"; + +interface Harness { + service: RemoteWorkspaceSessionService; + setOnline(value: boolean): void; + invocations: Array<{ tool: string; rootId: string }>; + closedSessions: string[]; + stopCalls(): number; + sessionOpens(): number; + sessionGrants: string[][]; + runtimeStarts(): Array; +} + +class MemorySessionStore implements RemoteWorkspaceSessionStateStore { + state: RemoteWorkspaceSessionState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceSessionState) { this.state = structuredClone(state); } +} + +function deferred(): { + promise: Promise; + resolve(): void; + reject(error: Error): void; +} { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createHarness(options: { + promptGate?: ReturnType; + startGate?: ReturnType; + onStart?: () => void; + lazyResumable?: boolean; + eventsAtStart?: number; + sessionStore?: RemoteWorkspaceSessionStateStore; + stopError?: Error; + closeError?: Error; +} = {}): Harness { + let online = true; + let stops = 0; + let opens = 0; + let promptStarted = false; + let runtimeResumable = options.lazyResumable !== true; + const invocations: Array<{ tool: string; rootId: string }> = []; + const closedSessions: string[] = []; + const sessionGrants: string[][] = []; + const transportStates: Array<{ online: boolean }> = []; + const runtimeStarts: Array = []; + const newTransport = (): RemoteWorkspaceTransport => { + const state = { online: true }; + transportStates.push(state); + return { + isOnline: deviceId => state.online && deviceId === DEVICE_ID, + async invoke(request) { + if (!state.online) throw new Error("transport offline"); + invocations.push({ tool: request.tool, rootId: request.rootId }); + return { ok: true, value: { entries: ["src"] } }; + }, + }; + }; + const connection = { + capabilities: () => ["workspace.read", "workspace.write", "workspace.exec"], + async openSession(input: { capabilities: string[] }) { sessionGrants.push([...input.capabilities]); opens += 1; return newTransport(); }, + async closeSession(sessionId: string) { + closedSessions.push(sessionId); + if (options.closeError) throw options.closeError; + }, + }; + const hub = { + listDevices: () => [{ + id: DEVICE_ID, + name: "Build box", + platform: "linux", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + roots: [{ id: ROOT_ID, label: "Project" }], + online, + createdAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: null, + }], + connection: (deviceId: string) => online && deviceId === DEVICE_ID ? connection : null, + } as unknown as RemoteWorkspaceHub; + + const factory: RemoteWorkspaceRuntimeFactory = { + profile: "codex", + async available() { return { available: true, version: "test" }; }, + async start({ coordinator, emit, resumeThreadId }) { + options.onStart?.(); + if (options.startGate) await options.startGate.promise; + runtimeStarts.push(resumeThreadId); + for (let index = 0; index < (options.eventsAtStart ?? 0); index += 1) { + emit("assistant", `event-${index}`); + } + const handle: RemoteWorkspaceRuntimeHandle = { + threadId: resumeThreadId ?? "thread-remote-1", + canResume: () => runtimeResumable, + async prompt() { + promptStarted = true; + if (options.promptGate) await options.promptGate.promise; + else { + const response = await coordinator.handle({ + method: "item/tool/call", + id: "tool-1", + params: { + threadId: "thread-remote-1", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "list_directory", + arguments: { path: "." }, + }, + }); + emit("tool", response.result.contentItems[0]!.text); + } + runtimeResumable = true; + }, + async stop() { + stops += 1; + if (promptStarted) options.promptGate?.reject(new Error("turn cancelled")); + if (options.stopError) throw options.stopError; + }, + }; + return handle; + }, + }; + return { + service: new RemoteWorkspaceSessionService(hub, [factory], Date.now, options.sessionStore), + setOnline(value) { + online = value; + if (!value) for (const state of transportStates) state.online = false; + }, + invocations, + closedSessions, + stopCalls: () => stops, + sessionOpens: () => opens, + sessionGrants, + runtimeStarts: () => [...runtimeStarts], + }; +} + +describe("Remote Workspace session service", () => { + test("binds one model session to the selected executor root", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.status).toBe("ready"); + expect(created.deviceName).toBe("Build box"); + expect(created.rootLabel).toBe("Project"); + expect(created).toMatchObject({ + accessMode: "read-only", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + + const completed = await harness.service.prompt(created.id, "Inspect this project"); + expect(completed.status).toBe("ready"); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + expect(completed.events.some(event => event.type === "tool" && event.text.includes("src"))).toBe(true); + }); + + test("exposes write and exec tools only after an explicit workspace access grant", async () => { + const harness = createHarness(); + const created = await harness.service.create({ + profile: "codex", + deviceId: DEVICE_ID, + rootId: ROOT_ID, + accessMode: "workspace", + }); + expect(created).toMatchObject({ + accessMode: "workspace", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + }); + + test("fails closed when the selected executor disconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + harness.setOnline(false); + await expect(harness.service.prompt(created.id, "Do not run locally")).rejects.toThrow("executor is offline"); + expect(harness.invocations).toHaveLength(0); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("reopens only the encrypted executor channel after the device reconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(harness.sessionOpens()).toBe(1); + harness.setOnline(false); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + harness.setOnline(true); + const completed = await harness.service.prompt(created.id, "Continue remotely"); + expect(completed.status).toBe("ready"); + expect(harness.sessionOpens()).toBe(2); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + }); + + test("rejects a second prompt while a turn is active", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const first = harness.service.prompt(created.id, "First"); + await Promise.resolve(); + await expect(harness.service.prompt(created.id, "Second")).rejects.toThrow("active turn"); + gate.resolve(); + await first; + }); + + test("a turn that finishes after disconnect stays waiting instead of reporting ready", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const running = harness.service.prompt(created.id, "Keep the target binding"); + await Promise.resolve(); + harness.setOnline(false); + gate.resolve(); + const completed = await running; + expect(completed.status).toBe("waiting_for_executor"); + }); + + test("stop cancels an active turn before waiting for it", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const promptOutcome = harness.service.prompt(created.id, "Long turn").then( + () => "resolved", + () => "rejected", + ); + await Promise.resolve(); + + expect(await harness.service.stop(created.id)).toBe(true); + expect(await promptOutcome).toBe("rejected"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("stopped"); + }); + + test("stop cannot be overwritten by a session that finishes starting late", async () => { + const startGate = deferred(); + const startEntered = deferred(); + const harness = createHarness({ startGate, onStart: startEntered.resolve }); + const creating = harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await startEntered.promise; + const starting = harness.service.list()[0]; + if (!starting) throw new Error("starting session was not visible"); + + expect(await harness.service.stop(starting.id)).toBe(true); + startGate.resolve(); + await expect(creating).rejects.toThrow("stopped while starting"); + expect(harness.service.get(starting.id)?.status).toBe("stopped"); + expect(harness.stopCalls()).toBe(1); + }); + + test("attempts every session cleanup owner and reports incomplete teardown", async () => { + const harness = createHarness({ + stopError: new Error("runtime refused to stop"), + closeError: new Error("transport refused to close"), + }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await expect(harness.service.stop(created.id)).rejects.toThrow("runtime refused to stop"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("failed"); + }); + + test("keeps only a bounded event history", async () => { + const harness = createHarness({ eventsAtStart: 510 }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.events).toHaveLength(100); + expect(created.events[0]!.sequence).toBeGreaterThan(1); + const types: RemoteWorkspaceSessionEvent["type"][] = created.events.map(event => event.type); + expect(types.at(-1)).toBe("status"); + }); + + test("restores a persisted Hub session and resumes its original model thread", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(store.state?.sessions[0]?.threadId).toBe("thread-remote-1"); + + const restarted = createHarness({ sessionStore: store }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + const completed = await restarted.service.prompt(created.id, "Continue after Hub restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("persists a lazy runtime as resumable only after its first completed turn", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store, lazyResumable: true }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.resumable).toBe(false); + expect(store.state?.sessions[0]?.resumable).toBe(false); + + const completed = await first.service.prompt(created.id, "Create durable history"); + expect(completed.resumable).toBe(true); + const restarted = createHarness({ sessionStore: store, lazyResumable: true }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("graceful Hub shutdown cleans runtimes without marking resumable sessions stopped", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await first.service.shutdown(); + expect(first.stopCalls()).toBe(1); + expect(store.state?.sessions[0]?.status).toBe("waiting_for_executor"); + + const restarted = createHarness({ sessionStore: store }); + const completed = await restarted.service.prompt(created.id, "Resume after graceful restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("stops every retained runtime during Hub shutdown", async () => { + const harness = createHarness(); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.stopAll(); + expect(harness.stopCalls()).toBe(2); + expect(harness.service.list().every(session => session.status === "stopped")).toBe(true); + }); +}); + + +test("read-only capability grant is forwarded on initial open and reconnect", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID, accessMode: "read-only" }); + expect(harness.sessionGrants).toEqual([["workspace.read"]]); + harness.setOnline(false); + harness.service.list(); + harness.setOnline(true); + await harness.service.prompt(created.id, "Read after reconnect"); + expect(harness.sessionGrants).toEqual([["workspace.read"], ["workspace.read"]]); + await harness.service.stop(created.id); +}); diff --git a/tests/clients/remote-workspace-tool-bridge.test.ts b/tests/clients/remote-workspace-tool-bridge.test.ts new file mode 100644 index 0000000000..48c24634b7 --- /dev/null +++ b/tests/clients/remote-workspace-tool-bridge.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { RemoteWorkspaceCoordinator, startRemoteWorkspaceToolBridge } from "../../src/remote-control"; + +test("loopback CLI bridge accepts only its bearer and delegates to the E2EE coordinator", async () => { + const invocations: string[] = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke(request) { + invocations.push(request.tool); + return { ok: true, value: { entries: ["src"] } }; + }, + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + const denied = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(denied.status).toBe(401); + const allowed = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(allowed.status).toBe(200); + const body = await allowed.json() as { success: boolean; text: string }; + expect(body.success).toBe(true); + expect(body.text).toContain("src"); + expect(invocations).toEqual(["list_directory"]); + } finally { + await bridge.stop(); + } +}); + +test("loopback CLI bridge rejects excess work before buffering another request", async () => { + const releases: Array<() => void> = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + invoke: async () => await new Promise<{ ok: true; value: null }>(resolve => { + releases.push(() => resolve({ ok: true, value: null })); + }), + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory"], + }); + const request = () => fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + try { + const active = Array.from({ length: 8 }, request); + for (let count = 0; count < 100 && releases.length < 8; count += 1) await Bun.sleep(1); + expect(releases).toHaveLength(8); + expect((await request()).status).toBe(429); + for (const release of releases) release(); + expect((await Promise.all(active)).every(response => response.status === 200)).toBe(true); + } finally { + for (const release of releases) release(); + await bridge.stop(); + } +}); diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts new file mode 100644 index 0000000000..6754d5b8ff --- /dev/null +++ b/tests/clients/remote-workspace.test.ts @@ -0,0 +1,464 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { + mkdirSync, + linkSync, + mkdtempSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + type AppServerDynamicToolRequest, + type RemoteWorkspaceCommandRunner, + type RemoteWorkspaceExecutionRequest, + type RemoteWorkspaceToolResult, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +const localTestCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local test command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local test command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-workspace-")); + roots.push(root); + const main = join(root, "main"); + const executorRoot = join(root, "executor"); + mkdirSync(join(main, "project"), { recursive: true }); + mkdirSync(join(executorRoot, "project"), { recursive: true }); + writeFileSync(join(main, "project", "marker.txt"), "main-only"); + writeFileSync(join(executorRoot, "project", "marker.txt"), "executor-before"); + const deviceId = `device-${randomUUID()}`; + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "project-root", path: executorRoot }], + commandRunner: localTestCommandRunner, + }); + let online = true; + let invokeCount = 0; + const transport: RemoteWorkspaceTransport = { + isOnline: candidate => online && candidate === deviceId, + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + invokeCount += 1; + return await executor.invoke(request); + }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadId = `thread-${randomUUID()}`; + coordinator.register({ + sessionId: `session-${randomUUID()}`, + threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const request = (tool: string, args: unknown, id: number = 1): AppServerDynamicToolRequest => ({ + method: "item/tool/call", + id, + params: { + threadId, + turnId: `turn-${randomUUID()}`, + callId: `call-${randomUUID()}`, + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { + root, + main, + executorRoot, + executor, + coordinator, + request, + setOnline(value: boolean) { online = value; }, + invokeCount: () => invokeCount, + }; +} + +function responseValue(response: Awaited>): RemoteWorkspaceToolResult { + return JSON.parse(response.result.contentItems[0]!.text) as RemoteWorkspaceToolResult; +} + +describe("remote workspace coordinator and executor", () => { + test("publishes only the namespaced client-executed tools and isolates the coordinator cwd", () => { + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS).toHaveLength(1); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].name).toBe(REMOTE_WORKSPACE_TOOL_NAMESPACE); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name)).toEqual([ + "list_directory", "read_file", "write_file", "exec", + ]); + const coordinatorIsolation = resolve("isolated-coordinator-session"); + const params = remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + expect(params).toMatchObject({ + cwd: coordinatorIsolation, + runtimeWorkspaceRoots: [coordinatorIsolation], + approvalPolicy: "never", + serviceName: "opencodex_remote_workspace", + }); + expect(String(params.developerInstructions)).toContain("never fall back locally"); + }); + + test("rejects write and exec calls that are outside the session access grant", async () => { + let invoked = false; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { invoked = true; return { ok: true }; }, + }); + coordinator.register({ + sessionId: "session-read-only", + threadId: "thread-read-only", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const result = await coordinator.handle({ + method: "item/tool/call", + id: "request-1", + params: { + threadId: "thread-read-only", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "exec", + arguments: { command: ["true"] }, + }, + }); + expect(responseValue(result).error).toContain("not supported"); + expect(invoked).toBe(false); + }); + + test("writes and executes only inside Computer 2 while the same Computer 1 path stays unchanged", async () => { + const state = fixture(); + const write = await state.coordinator.handle(state.request("write_file", { + path: "project/marker.txt", + content: "executor-after", + expectedSha256: sha256("executor-before"), + })); + expect(write.result.success).toBe(true); + expect(responseValue(write).ok).toBe(true); + expect(readFileSync(join(state.executorRoot, "project", "marker.txt"), "utf8")).toBe("executor-after"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + + const command = process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", "Write-Output -NoNewline 'executor-process:'; (Get-Location).Path"] + : ["/bin/sh", "-lc", "printf 'executor-process:'; pwd"]; + const exec = await state.coordinator.handle(state.request("exec", { + command, + cwd: "project", + timeoutMs: 5_000, + }, 2)); + const result = responseValue(exec); + expect(exec.result.success).toBe(true); + expect(result.ok).toBe(true); + expect(JSON.stringify(result.value)).toContain("executor-process:"); + expect(JSON.stringify(result.value)).toContain(join(state.executorRoot, "project")); + expect(JSON.stringify(result.value)).not.toContain(state.main); + }); + + test("lists and reads bounded workspace data through the selected root", async () => { + const state = fixture(); + const list = responseValue(await state.coordinator.handle(state.request("list_directory", { path: "project" }))); + expect(list).toMatchObject({ ok: true, value: { path: "project" } }); + expect(JSON.stringify(list.value)).toContain("marker.txt"); + + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + maxBytes: 1024, + }))); + expect(read).toMatchObject({ ok: true, value: { content: "executor-before", bytes: 15 } }); + expect((read.value as { sha256: string }).sha256).toBe(sha256("executor-before")); + }); + + test("does not read an unbounded existing file while checking a write precondition", async () => { + const state = fixture(); + writeFileSync( + join(state.executorRoot, "project", "oversized.txt"), + Buffer.alloc(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES + 1), + ); + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/oversized.txt", + content: "replacement", + expectedSha256: "0".repeat(64), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("read limit"); + }); + + test("rejects traversal and symlink escapes on the executor", async () => { + const state = fixture(); + const traversal = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "../main/project/marker.txt", + }))); + expect(traversal.ok).toBe(false); + expect(traversal.error).toContain("escapes"); + + symlinkSync( + join(state.main, "project"), + join(state.executorRoot, "outside-link"), + process.platform === "win32" ? "junction" : "dir", + ); + const symlink = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "outside-link/marker.txt", + }))); + expect(symlink.ok).toBe(false); + expect(symlink.error).toContain("symlink"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + }); + + test("rejects hardlink aliases for both file reads and writes", async () => { + const state = fixture(); + const outside = join(state.main, "project", "marker.txt"); + linkSync(outside, join(state.executorRoot, "project", "outside-alias.txt")); + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/outside-alias.txt", + }))); + expect(read.ok).toBe(false); + expect(read.error).toContain("hard-linked"); + + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/outside-alias.txt", + content: "escaped", + expectedSha256: sha256("main-only"), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("hard-linked"); + expect(readFileSync(outside, "utf8")).toBe("main-only"); + }); + + test("rejects a workspace root replaced after local approval", async () => { + const state = fixture(); + renameSync(state.executorRoot, `${state.executorRoot}-approved`); + mkdirSync(join(state.executorRoot, "project"), { recursive: true }); + writeFileSync(join(state.executorRoot, "project", "marker.txt"), "replacement-root"); + const result = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("root identity changed"); + }); + + test("fails closed while the selected executor is offline and never invokes another path", async () => { + const state = fixture(); + state.setOnline(false); + const response = await state.coordinator.handle(state.request("exec", { + command: ["/bin/true"], + })); + expect(response.result.success).toBe(false); + expect(responseValue(response).error).toContain("local fallback is disabled"); + expect(state.invokeCount()).toBe(0); + }); + + test("keeps command execution disabled by default until an OS sandbox is supplied", async () => { + const state = fixture(); + const locked = new RemoteWorkspaceExecutor({ + deviceId: "locked-device", + roots: [{ id: "project-root", path: state.executorRoot }], + }); + const result = await locked.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: "locked-device", + rootId: "project-root", + tool: "exec", + arguments: { command: ["/bin/true"] }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("OS sandbox"); + }); + + test("rejects unbound threads and non-remote namespaces before transport", async () => { + const state = fixture(); + const unbound = state.request("read_file", { path: "project/marker.txt" }); + (unbound.params as Record).threadId = `other-${randomUUID()}`; + expect(responseValue(await state.coordinator.handle(unbound)).error).toContain("not bound"); + + const wrongNamespace = state.request("read_file", { path: "project/marker.txt" }); + (wrongNamespace.params as Record).namespace = "local_workspace"; + expect(responseValue(await state.coordinator.handle(wrongNamespace)).error).toContain("identity"); + expect(state.invokeCount()).toBe(0); + }); + + test("carries coordinator requests and executor results over the authenticated E2EE channel", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + const encryptedExecutor = new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: encryptedExecutor, + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const result = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "executor-before" } }); + expect(JSON.stringify(result)).not.toContain(state.main); + client.close(); + }); + + test("fragments large writes and reads without raising the relay frame memory limit", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + const content = "remote-fragment\n".repeat(10_000); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }), + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const write = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "write_file", + arguments: { path: "project/large.txt", content, expectedSha256: null }, + }); + expect(write).toMatchObject({ ok: true, value: { bytes: Buffer.byteLength(content) } }); + const read = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/large.txt", maxBytes: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + }); + expect(read).toMatchObject({ ok: true, value: { content } }); + client.close(); + endpoint.close(); + }); +}); diff --git a/tests/fake-codex-server.ts b/tests/fake-codex-server.ts index dc71863a1b..e77a763d45 100644 --- a/tests/fake-codex-server.ts +++ b/tests/fake-codex-server.ts @@ -177,6 +177,10 @@ async function handleMessage(msg: Record): Promise { return; } switch (method) { + case "config/read": { + respond(id, { config: {}, origins: {}, layers: null }); + return; + } case "thread/start": { if (script.rejectThreadStart) { respondError(id, script.rejectThreadStart); diff --git a/tests/fixtures/fake-claude-stream.ts b/tests/fixtures/fake-claude-stream.ts new file mode 100644 index 0000000000..3f1c7bb9f3 --- /dev/null +++ b/tests/fixtures/fake-claude-stream.ts @@ -0,0 +1,8 @@ +let input = ""; +for await (const chunk of Bun.stdin.stream()) input += new TextDecoder().decode(chunk); +const text = input.trim() ? `Hub answer: ${input.trim()}` : "Hub answer"; +process.stdout.write(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text }] }, +})}\n`); +process.stdout.write(`${JSON.stringify({ type: "result", is_error: false, result: text })}\n`); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6e98e236f0..efaa7923a8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -886,6 +886,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", From 59ec04b907b56a324971f23fd5350795f3039021 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:00:49 +0900 Subject: [PATCH 08/68] fix(cursor): preserve first overflow and bound stable-thread remints Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 8 + scripts/test-layout/layout.json | 1 + src/adapters/cursor.ts | 176 ++++++---- src/adapters/cursor/cursor-errors.ts | 12 + src/adapters/cursor/thread-continuity.ts | 87 +++++ structure/providers/cursor.md | 4 + tests/fixtures/test-layout-expected.json | 1 + tests/providers/cursor/cursor-adapter.test.ts | 327 ++++++++++++++++++ .../cursor-continuity-retention.test.ts | 27 ++ 9 files changed, 570 insertions(+), 73 deletions(-) create mode 100644 tests/providers/cursor/cursor-continuity-retention.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..865cb63834 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Cursor context overflow + +Cursor's first bare context overflow is surfaced to the client. Later eligible requests +with a stable client thread may recover with up to three conversation remints per retained +scope. The in-memory allowance expires after one idle hour, eviction, or restart. Requests +without a stable thread, tool-result resumes, partial output, compaction and quota errors do +not use this recovery. This does not infer whether a task is making progress. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..8a40a91e13 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -538,6 +538,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 7382c25d8c..a240ae1982 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -31,7 +31,14 @@ import { debugProviderDiagnostic } from "../lib/debug"; import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; -import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; +import { + cursorOverflowRemintScopeKey, + markCursorOverflowSurfaced, + recordCursorOverflowRemint, + rememberCursorThreadConversation, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions"; import { @@ -399,84 +406,107 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ); }; - try { - await runOnce(request); - } catch (err) { - const outputGuardRetryText = - err instanceof CursorToolResultEchoError - ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT - : err instanceof CursorRoutingCommentaryError - ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT - : undefined; - // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). - // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation - // retry is safe. A second rejection propagates as an error rather than looping. - if ( - outputGuardRetryText - && !emittedOutput - && !replayUnsafe - && !incoming.abortSignal?.aborted - ) { - debugProviderDiagnostic( - "cursor", - err instanceof CursorToolResultEchoError - ? "envelope-echo-retry" - : "routing-commentary-retry", - { - wireModel: request.modelId, - conversationHash: request.conversationId.slice(0, 16), - }, + const remintConversationId = (failedConversationId: string) => { + lastTransport = undefined; + _parsed._cursorConversationId = undefined; + const next = createCursorRequest(_parsed, { forceFreshConversation: true }); + rekeyContextUsage(failedConversationId, next.conversationId); + _parsed._cursorConversationId = next.conversationId; + // Persist recovery for store:false clients that send any stable Cursor thread owner, so + // the next turn does not recompute the stale deterministic thread hash. Isolated helper / + // compaction turns must not park their throwaway id under the parent or Desktop owner. + const threadOwner = cursorClientThreadOwner(_parsed); + if (threadOwner && _parsed._cursorIsolateConversation !== true) { + rememberCursorThreadConversation( + threadOwner, + next.conversationId, + _parsed._cursorIdentityScope, ); - const echoedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), - echoRetryContinuationText: outputGuardRetryText, - }; - rekeyContextUsage(echoedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - const echoThreadOwner = cursorClientThreadOwner(_parsed); - if (echoThreadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - echoThreadOwner, - request.conversationId, - _parsed._cursorIdentityScope, - ); - } + } + return next; + }; + + for (;;) { + try { await runOnce(request); - } else { - // One-shot fallback for external-model Connect invalid_argument before any - // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result - // resumes, local exec/MCP side effects, and already-emitted output fail closed. + break; + } catch (err) { + const outputGuardRetryText = + err instanceof CursorToolResultEchoError + ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT + : err instanceof CursorRoutingCommentaryError + ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT + : undefined; + // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). + // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation + // retry is safe. A second rejection propagates as an error rather than looping. if ( - !isCursorInvalidArgumentError(err) - || !isCursorExternalWireModel(request.modelId) - || lastRawIsToolResult - || emittedOutput - || replayUnsafe - || incoming.abortSignal?.aborted + outputGuardRetryText + && !emittedOutput + && !replayUnsafe + && !incoming.abortSignal?.aborted ) { - throw err; - } - const failedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = createCursorRequest(_parsed, { forceFreshConversation: true }); - rekeyContextUsage(failedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - // Persist recovery for store:false clients that send any stable Cursor thread owner, so - // the next turn does not recompute the stale deterministic thread hash. Isolated helper / - // compaction turns must not park their throwaway id under the parent or Desktop owner. - const threadOwner = cursorClientThreadOwner(_parsed); - if (threadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - threadOwner, - request.conversationId, + debugProviderDiagnostic( + "cursor", + err instanceof CursorToolResultEchoError + ? "envelope-echo-retry" + : "routing-commentary-retry", + { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }, + ); + const echoedConversationId = request.conversationId; + request = { + ...remintConversationId(echoedConversationId), + echoRetryContinuationText: outputGuardRetryText, + }; + await runOnce(request); + break; + } else { + const overflowRemintSafe = + !lastRawIsToolResult + && !emittedOutput + && !replayUnsafe + && request.contextUsageStoreCheckpoints !== false + && !incoming.abortSignal?.aborted; + const overflowScopeKey = cursorOverflowRemintScopeKey( + cursorClientThreadOwner(_parsed), _parsed._cursorIdentityScope, ); + if ( + overflowScopeKey + && overflowRemintSafe + && isCursorOverflowRemintCandidate(err, requestSizeContext) + ) { + if (shouldSkipCursorOverflowRemint(overflowScopeKey)) throw err; + if (shouldSurfaceCursorOverflowFirst(overflowScopeKey)) { + markCursorOverflowSurfaced(overflowScopeKey); + throw err; + } + if (!recordCursorOverflowRemint(overflowScopeKey)) throw err; + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + request = remintConversationId(request.conversationId); + continue; + } + + // One-shot fallback for external-model Connect invalid_argument before any + // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result + // resumes, local exec/MCP side effects, and already-emitted output fail closed. + if ( + !isCursorInvalidArgumentError(err) + || !isCursorExternalWireModel(request.modelId) + || lastRawIsToolResult + || emittedOutput + || replayUnsafe + || incoming.abortSignal?.aborted + ) { + throw err; + } + request = remintConversationId(request.conversationId); + await runOnce(request); + break; } - await runOnce(request); } } if ( diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index b7005db3c2..fba44b2f99 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -191,6 +191,18 @@ function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; } +/** + * True when a transport error is the bare 0-token resource_exhausted overflow shape + * (not quota/rate) that should surface for Codex compact or remint on later hits. + */ +export function isCursorOverflowRemintCandidate(err: unknown, sizeContext?: CursorSizeContext): boolean { + const message = errorMessage(err); + if (!message) return false; + const lower = message.toLowerCase(); + if (!isCursorZeroTokenResourceExhausted(lower)) return false; + return classifyCursorError(message, sizeContext) === "Cursor context limit exceeded"; +} + export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; // Any explicit quota/rate cue wins: this is a real 429. diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index 6cb0e2cf35..aa3c3dac32 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -65,3 +65,90 @@ export function lookupCursorThreadConversation( export function clearCursorThreadContinuityForTests(): void { overrides.clear(); } + +/** Max conversation-id remints after the first surfaced overflow per retained scope. */ +export const CURSOR_OVERFLOW_REMINT_MAX = 3; +export const CURSOR_OVERFLOW_REMINT_TTL_MS = 60 * 60 * 1000; +export const CURSOR_OVERFLOW_REMINT_MAX_ENTRIES = 2_048; + +type OverflowRemintState = { + surfaced: boolean; + remintCount: number; + skip: boolean; + updatedAt: number; +}; + +const overflowRemintByScope = new Map(); + +function pruneOverflowRemints(at: number): void { + for (const [scopeKey, entry] of overflowRemintByScope) { + if (at - entry.updatedAt > CURSOR_OVERFLOW_REMINT_TTL_MS) overflowRemintByScope.delete(scopeKey); + } + while (overflowRemintByScope.size > CURSOR_OVERFLOW_REMINT_MAX_ENTRIES) { + const oldest = overflowRemintByScope.keys().next().value; + if (oldest === undefined) break; + overflowRemintByScope.delete(oldest); + } +} + +function overflowRemintEntry(scopeKey: string): OverflowRemintState { + const at = now(); + pruneOverflowRemints(at); + const existing = overflowRemintByScope.get(scopeKey); + if (existing) { + existing.updatedAt = at; + overflowRemintByScope.delete(scopeKey); + overflowRemintByScope.set(scopeKey, existing); + return existing; + } + const fresh: OverflowRemintState = { surfaced: false, remintCount: 0, skip: false, updatedAt: at }; + overflowRemintByScope.set(scopeKey, fresh); + pruneOverflowRemints(at); + return fresh; +} + +/** Stable client-thread ownership survives conversation remints; wire ids alone do not. */ +export function cursorOverflowRemintScopeKey( + threadOwner: string | undefined, + identityScope?: string, +): string | null { + if (!threadOwner) return null; + return `overflow\0${cursorThreadScopeKey(threadOwner, identityScope)}`; +} + +/** True until the first overflow for this scope has been surfaced for Codex compact. */ +export function shouldSurfaceCursorOverflowFirst(scopeKey: string): boolean { + pruneOverflowRemints(now()); + return overflowRemintByScope.get(scopeKey)?.surfaced !== true; +} + +export function markCursorOverflowSurfaced(scopeKey: string): void { + const entry = overflowRemintEntry(scopeKey); + entry.surfaced = true; +} + +export function shouldSkipCursorOverflowRemint(scopeKey: string): boolean { + pruneOverflowRemints(now()); + const entry = overflowRemintByScope.get(scopeKey); + return entry?.skip === true || (entry?.remintCount ?? 0) >= CURSOR_OVERFLOW_REMINT_MAX; +} + +/** Record one overflow remint; returns false when the cap is exhausted. */ +export function recordCursorOverflowRemint(scopeKey: string): boolean { + const entry = overflowRemintEntry(scopeKey); + if (entry.skip || entry.remintCount >= CURSOR_OVERFLOW_REMINT_MAX) { + entry.skip = true; + return false; + } + entry.remintCount += 1; + return true; +} + +export function clearCursorOverflowRemintForTests(): void { + overflowRemintByScope.clear(); +} + +export function cursorOverflowRemintCountForTests(): number { + pruneOverflowRemints(now()); + return overflowRemintByScope.size; +} diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..3d734a33ed 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,7 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +## Overflow remint boundary + +`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects and compaction remain fail-closed. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..cf071d04fc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -373,6 +373,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index a86df7c243..82049b83f4 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -4,6 +4,7 @@ import { cursorExecDeniedMessage, } from "../../../src/adapters/cursor"; import { + clearCursorOverflowRemintForTests, clearCursorThreadContinuityForTests, lookupCursorThreadConversation, } from "../../../src/adapters/cursor/thread-continuity"; @@ -817,3 +818,329 @@ describe("Cursor adapter live transport", () => { clearCursorCheckpointsForTests(); }); }); +const LARGE_OVERFLOW_CONTENT = "word ".repeat(100_000); + +function bareOverflowError(): Error { + return Object.assign( + new Error("Cursor context limit exceeded: Cursor Connect error resource_exhausted: Error"), + { code: "resource_exhausted" }, + ); +} + +function overflowTurnBody(threadId?: string): OcxParsedRequest { + return { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-overflow-remint", + ...(threadId ? { _clientThreadId: threadId } : { _cursorConversationId: "cursor_overflow_base" }), + }; +} + +describe("Cursor overflow conversation remint", () => { + test("first bare overflow surfaces without reminting the conversation id", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-surface-first"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("second overflow remints and persists thread override", async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + if (attempts === 1) { + throw bareOverflowError(); + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + + const threadId = "overflow-remint-thread"; + const body = overflowTurnBody(threadId); + + const surfaceEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => surfaceEvents.push(event)); + expect(attempts).toBe(1); + expect(surfaceEvents.some(event => event.type === "error")).toBe(true); + + seen.length = 0; + attempts = 0; + const remintEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => remintEvents.push(event)); + + expect(attempts).toBe(2); + expect(seen).toHaveLength(2); + expect(seen[1]).not.toBe(seen[0]); + expect(remintEvents.some(event => event.type === "done")).toBe(true); + expect(lookupCursorThreadConversation(threadId, "acct-overflow-remint")).toBe(seen[1]); + expect(body._cursorConversationId).toBe(seen[1]); + }); + + test("fourth overflow skips remint after surface-first and three remints", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-cap-skip"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + + attempts = 0; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(4); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("quota-cue resource_exhausted does not remint and surfaces as rate limit", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw Object.assign( + new Error("Cursor rate limit exceeded: resource_exhausted: too many requests"), + { code: "resource_exhausted" }, + ); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-quota-cue"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor rate limit exceeded"), + }); + }); + + test("does not overflow-remint on tool-result resumes", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body: OcxParsedRequest = { + modelId: "cursor/auto", + context: { + messages: [ + { role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", namespace: "mcp__fs", arguments: { path: "a.txt" } }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + toolNamespace: "mcp__fs", + content: "FILE CONTENTS HERE", + isError: false, + timestamp: 3, + }, + ], + }, + stream: false, + options: {}, + _cursorConversationId: "cursor_overflow_tool", + _cursorIdentityScope: "acct-overflow-remint", + }; + + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + }); + + test("does not overflow-remint compaction turns", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-compaction"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + seen.length = 0; + body._compactionRequest = true; + body._cursorIsolateConversation = true; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + }); + + test("does not overflow-remint after non-heartbeat output was emitted", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + yield { type: "text", text: "partial" } satisfies CursorServerMessage; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-after-output"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events.some(event => event.type === "text_delta")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(true); + }); +}); + + +describe("Cursor overflow accounting across requests", () => { + for (const ownerField of ["_clientThreadId", "_cursorClientThreadId"] as const) { + test(`${ownerField} retains the cap across successful remints`, async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + let failNext = true; + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + attempts++; + seen.push(request.conversationId); + if (failNext) { failNext = false; throw bareOverflowError(); } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + const body = () => { + const parsed = overflowTurnBody(); + parsed._cursorConversationId = undefined; + parsed[ownerField] = `cross-request-${ownerField}`; + return parsed; + }; + await adapter.runTurn?.(body(), { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + for (let remint = 0; remint < 3; remint++) { + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(2); + expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); + expect(events.some(event => event.type === "done")).toBe(true); + } + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(1); + expect(events.some(event => event.type === "error")).toBe(true); + }); + } + test("conversation-only clients never gain an automatic remint allowance", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run() { attempts++; throw bareOverflowError(); }, + writeClient() {}, + }), + }); + for (let turn = 0; turn < 3; turn++) { + const events: AdapterEvent[] = []; + await adapter.runTurn?.(overflowTurnBody(), { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(turn + 1); + expect(events.some(event => event.type === "error")).toBe(true); + } + }); +}); diff --git a/tests/providers/cursor/cursor-continuity-retention.test.ts b/tests/providers/cursor/cursor-continuity-retention.test.ts new file mode 100644 index 0000000000..2d3f833e8f --- /dev/null +++ b/tests/providers/cursor/cursor-continuity-retention.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + clearCursorOverflowRemintForTests, + CURSOR_OVERFLOW_REMINT_MAX_ENTRIES, + cursorOverflowRemintCountForTests, + markCursorOverflowSurfaced, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "../../../src/adapters/cursor/thread-continuity"; + +describe("Cursor overflow remint retention", () => { + test("bounds per-scope state", () => { + clearCursorOverflowRemintForTests(); + for (let index = 0; index < CURSOR_OVERFLOW_REMINT_MAX_ENTRIES + 20; index++) { + markCursorOverflowSurfaced(`scope-${index}`); + } + expect(cursorOverflowRemintCountForTests()).toBe(CURSOR_OVERFLOW_REMINT_MAX_ENTRIES); + clearCursorOverflowRemintForTests(); + }); + + test("read-only checks do not allocate retention entries", () => { + clearCursorOverflowRemintForTests(); + expect(shouldSurfaceCursorOverflowFirst("missing")).toBe(true); + expect(shouldSkipCursorOverflowRemint("missing")).toBe(false); + expect(cursorOverflowRemintCountForTests()).toBe(0); + }); +}); From a3182185f0e089504d72e5729e4674cf0dc07ea1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:09 +0900 Subject: [PATCH 09/68] style(remote): remove trailing blank line in runner --- src/remote-control/workspace-command-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts index f9a3625caa..1b2fe74628 100644 --- a/src/remote-control/workspace-command-runner.ts +++ b/src/remote-control/workspace-command-runner.ts @@ -746,4 +746,3 @@ export function linuxRemoteWorkspaceCommandRunnerAvailable( availabilityCache.set(cacheKey, available); return available; } - From b2d239ea172f92d65bab206d3301931eb3b9bfd9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:25 +0900 Subject: [PATCH 10/68] fix: bound multipart encrypted agent task recovery Preserve ordered whole-token parts and input identity through one admitted recovery request. Detect split-token structure without authorizing reconstruction. Local tests NOT RUN by maintainer instruction; hosted CI follows. --- devlog/_plan/260912_v2_contracts/000_plan.md | 26 ++++ .../260912_v2_contracts/010_plaintext.md | 38 ++++++ .../_plan/260912_v2_contracts/020_recovery.md | 50 ++++++++ .../260912_v2_contracts/030_verification.md | 9 ++ .../docs/reference/configuration/agents.md | 13 ++ src/server/responses/agent-task-recovery.ts | 57 ++++----- src/server/responses/encrypted-payload.ts | 47 +++++++- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 14 +++ structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + .../agent-task-recovery-security.test.ts | 4 +- tests/server/agent-task-recovery.test.ts | 114 ++++++++++++++++++ 23 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 devlog/_plan/260912_v2_contracts/000_plan.md create mode 100644 devlog/_plan/260912_v2_contracts/010_plaintext.md create mode 100644 devlog/_plan/260912_v2_contracts/020_recovery.md create mode 100644 devlog/_plan/260912_v2_contracts/030_verification.md diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md new file mode 100644 index 0000000000..5ba4c2022d --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -0,0 +1,26 @@ +# V2 delegation contracts + +This unit reconciles plaintext prevention (#2495) separately from encrypted task recovery (#3661). Eligible native parents may opt into plaintext V2 calls; recovery continues to use its existing authenticated, bounded path. The replacement candidates #4242/#4243 are compared against the exact issue contract before any adoption. + +Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs and final cumulative hosted CI evidence. Non-goals: merges, issue closure, releases, installed service/config changes, native GitHub stacks, local product tests/build/typecheck/install. Local tests are NOT RUN by explicit instruction. Verification: source/diff checks during each cycle; Cross-platform CI on the final published head, with run IDs and conclusions retained. Stop: implementation, audit and CI evidence handed to the integration owner; no claim of integration. Outcomes: DONE with evidence, or an explicit unresolved acceptance/gate. Artifacts: this unit plus ignored `.tmp/v2/` evidence. Escalation: real tool denials and unresolved security/contract blockers are recorded; no new access/settings. Resource bounds: available account/tool permissions, this worktree only, no user token/time/agent-count cap. + +| Cycle | Outcome | Design | +|---|---|---| +| wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | +| wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | +| wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | +| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | + +wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. + +Existing owners: `src/adapters/openai-responses.ts`, `src/server/responses/core.ts`, `src/server/responses/agent-task-recovery.ts`; tests remain under domain directories. Source-of-truth pages are mapped by `structure/INDEX.md`. Reuse these owners, not a second server/recovery subsystem. Do-nothing/config-only alternatives cannot provide the missing wire behavior. + +Generic supported inherited-model subagents provide independent design consultation and separate review. Native architect selection is unavailable and is not claimed. Original contributor attribution follows the adopted source, including Sigurd-git for #2496 and SB Yoon if any #4242 code is carried. Source PRs/issues remain open or closed in their current state until the integration owner decides. + +## Cycle record + +wp0: P entered with own session binding; roadmap in progress. Product validation NOT RUN. + +wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. + +wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. diff --git a/devlog/_plan/260912_v2_contracts/010_plaintext.md b/devlog/_plan/260912_v2_contracts/010_plaintext.md new file mode 100644 index 0000000000..4551da65cb --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/010_plaintext.md @@ -0,0 +1,38 @@ +# Plaintext V2 prevention + +Class C4 public wire/retention boundary; consumes wp0. Source proposal: #2496 at 1a4cb4aab14200ec2efa71aea00d2a55fc90aca7. Exact public patch is the starting implementation specification, ported to current owners below. #4242 and #4243 are alternatives, not automatically dependencies. + +| Action | Path | Before → after | +|---|---|---| +| NEW | `src/responses/plaintext-v2-agent-messages.ts` | no explicit canonical exception → #2496 request compiler and bounded restoration helper, corrected by D2–D4 below | +| MODIFY | `src/types/config.ts`, `src/config.ts` | absent flag → optional `plaintextV2AgentMessages?: boolean`, unset default; malformed reads drop only field, candidate writes reject | +| MODIFY | `src/types/request.ts` | absent route marker → optional request-local `_plaintextV2AgentMessages` | +| MODIFY | `src/adapters/base.ts`, `src/adapters/openai-responses.ts` | no alias capabilities → adapter-produced request-owned tool-name sets after canonical opt-in rewrite | +| MODIFY | `src/server/responses/core.ts` | direct native passthrough → final-route opt-in preparation, alias metadata refreshed after each build, restoration before client/cache on every JSON/SSE/WS path | +| MODIFY | `src/server/index.ts` | recovery-only warning → separate opt-in plaintext retention warning | +| NEW | `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts` | absent → port #2496 tests and add refusal/collision cases | +| MODIFY | `tests/server/config.test.ts`, `tests/server/agent-task-recovery.test.ts`, `tests/responses/ws-upstream.test.ts` | existing adjacent contracts → port applicable #2496 regression deltas | +| MODIFY | `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json` | no new tests → register both new domain paths | +| MODIFY | English and zh-cn `guides/sub-agent-surface.md`, `reference/configuration/agents.md` under `docs-site/src/content/docs/` | recovery/V1 alternatives → config-only experimental plaintext contract and retention warning | +| MODIFY | applicable `structure/` owners from INDEX | current ownership descriptions → point to canonical plaintext contract without duplicating unrelated subsystem behavior | + +D1: preserve the explicit issue option; no management toggle or routed mirror catalog. +D2: `shouldPreparePlaintextV2AgentMessages`: true only for Responses wire, final canonical ChatGPT forward destination and default top-level collaboration catalog; additional_tools-only catalogs do not activate it. +D3: `preparePlaintextV2AgentMessages`: copy-on-write namespace + three tool aliases. Only `message.encrypted === true` is removed. Scan declaration/reference identity positions, including nested catalogs and qualified alias names, before any rewrite. Refuse all on any collision; foreign namespaces stay untouched. +D4: `restorePlaintextV2AgentMessageCalls*`: restore only request-generated identity capabilities. Preserve marker `encrypted_function_args: []`. Treat malformed JSON, unknown private identities, binding conflict and >10,000 identities as refusal. Bounded JSON returns 502; streams emit response.failed; refusal has no retry and no continuation write. Refresh state per turn/build; no connection/global alias state. + +Field chain: config type → config schema/save → config load/candidate validation → final route marker → adapter body serialization and AdapterRequest metadata → response restoration. Metadata is in-process only, never serialized as response fields or persisted with previous_response_id. Startup consumes config for warning. No new public state enum. + +Activation matrix: disabled/malformed flag, noncanonical/key/Anthropic/routed parent/V1/custom namespace unchanged; true canonical declaration rewritten without input mutation; each collision location leaves whole request unchanged; known aliases restored for JSON/SSE/WS and snapshots; same aliases under foreign namespace unchanged; malformed/overlimit/conflict terminal refused and not cached; next turn disabled and concurrent requests do not inherit prior metadata. Use hosted tests only; no live-account canary is claimed. + +Guard strength: runtime explicit option + compiler/restorer are code-path controls; operator can disable the option, which selects ordinary encrypted behavior. No credential authorization is added. Residual undocumented upstream behavior and plaintext retention are documented, not described as encryption guarantees. + +Port mapping verified against current tree: old `tests/config.test.ts` is now `tests/server/config.test.ts`; old `tests/ws-upstream.test.ts` is now `tests/responses/ws-upstream.test.ts`. A `git apply --check` of supporting #2496 hunks fails at current adapter/config/startup context; manual semantic port is required, not blind cherry-pick. Pure helper and new tests can use their full public source bodies with adjusted imports. Existing test helper `repo-root.ts` supplies repository paths instead of legacy relative directory inference. + +Exact integration replacements: `refreshRoutedNamespaceToolAliases` at core line 4702 becomes `refreshRequestToolAliases`, assigning both alias sets from each AdapterRequest or fresh empty sets. All seven current callers are renamed. At core `rememberPassthroughResponseChecked`, change `const restoredResponse = normalized...` to an intermediate normalized value; run plaintext restoration and return immediately on refusal before `rememberPassthroughResponse`. In blockRewrites, insert plaintext restoration immediately after `createResponsesSnapshotBlockRewrite`, before field backfill and guard. In bounded JSON, apply restoration after `normalizeFunctionCompletionJson` and before model rewrite; a refusal short-circuits before `rememberPassthroughResponseChecked`. At unsupported passthrough fallback, cancel response body and return safe 502 whenever request alias sets are nonempty. + +A synthesis WP1-A01: early raw inspection cannot authorize continuation for plaintext turns. Disable its cache callbacks when aliases are active. Publish only from a post-restoration/post-guard client block observer, and only after shared request-local stream validation accepts the terminal. Bounded JSON caches only its final restored value. Stream malformed/conflicting/overlimit rejection permanently prevents publication. + +WP1 implementation-P revalidation: prior D locked the roadmap. Reuse `createSseInspector` for restored client blocks: append a final block observer after the alias restorer and undeclared-tool guard, feed `${block}\n\n`, and dispose with the composed rewrite. Raw inspector callbacks are suppressed only for active plaintext aliases. The existing collector reconstructs output from accepted events. This gives one validated publication path rather than parallel raw/client cache decisions. Keep the final marker-preserving restorer before this collector. Unknown private identities throw before collection; terminal-only valid complete snapshots are accepted, so there is no invented requirement for prior added frames. + +Exact source-of-truth canonical owner is `structure/subagents.md`; add concise links from the mapped affected owners `runtime.md`, `config.md`, `overview.md`, `catalog.md`, `transports/responses.md`, `transports/streaming-health.md`, `transports/inventory.md`, `data-planes/images.md`, `data-planes/inbound-compat.md`, `providers/openai-tiers.md`, `providers/cursor.md`, `providers/chat-compat.md`, `providers/kiro.md`, `providers/xai-grok.md`, `adapters/registry.md`, `gui-and-management-api.md`, `clients/claude-desktop.md`, `ops/service-and-sidecars.md`, and `ops/docs-and-release.md` where the source-area map requires same-change synchronization. Links distinguish unchanged surfaces from the canonical new contract. diff --git a/devlog/_plan/260912_v2_contracts/020_recovery.md b/devlog/_plan/260912_v2_contracts/020_recovery.md new file mode 100644 index 0000000000..7dbcb075db --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/020_recovery.md @@ -0,0 +1,50 @@ +# Encrypted envelope recovery + +Class C4 authenticated plaintext boundary; consumes roadmap and independent envelope design. #3794 diagnostics and MESSAGE support are already present. This phase preserves them and never adds automatic outage retries. + +| Action | Path | Before → after | +|---|---|---| +| MODIFY | `src/server/responses/agent-task-recovery.ts` | single encryptedIndex/ciphertext → ordered bounded part descriptors and exact envelope snapshot; single backend recovery request; atomic input revalidation before replacement | +| MODIFY | `tests/server/agent-task-recovery.test.ts` | single-part coverage → ordered multipart, invalid/ambiguous fragments, size/count cap, input mutation and cache isolation cases | +| MODIFY | `docs-site/src/content/docs/reference/configuration/agents.md` | narrow recovery description → exact supported multipart shape, no blind retries and residual fragment limitations | +| MODIFY | relevant `structure/` owners | current single-part invariant → canonical bounded envelope contract | + +D5 proposal for design audit: accept a contiguous run of complete structurally valid Fernet strings, at most 32 parts and 2 MiB combined. Keep routing header singular and author/recipient equal to sender/task. Forward original complete token parts in their order to the same fixed backend endpoint once. Partial token strings remain unsupported unless source evidence establishes an unambiguous join contract; do not infer authentication from a plausible Fernet shape. + +`AgentEnvelope` replaces encryptedIndex with an ordered part list. The cache key hashes a length-delimited serialized token array (not ambiguous string concatenation). `recoveryPayload` maps these parts into its one input message. `injectAssignment` reruns envelope parsing and compares the full admitted snapshot (header, identities, positions, all ciphertext parts) before one content splice, then removes agent routing identity fields exactly as today. Existing admission is still before every cache read. Input mutation causes input_changed and cache discard. + +Creation → serialization → consumption: parser builds ordered part descriptors; recoveryPayload emits each validated whole part; cache key binds their order and boundaries; injection validates the original current input and writes one assignment. No new stored config or failure enum is needed; unsupported_envelope remains not attempted and existing typed request failures remain attempted/capacity outcomes. + +Activation matrix: one complete part unchanged; two complete ordered parts reach exactly one mocked backend call and one plaintext replacement; swapped tokens have distinct cache identity; wrong sender/recipient/header rejected without fetch; interleaved plaintext/noncontiguous encrypted parts rejected; empty, malformed, excessive count or total bytes rejected; delayed input mutation refuses assignment; HTTP 5xx yields the existing typed reason after one call; no retry budget increase. Reuse existing helper fixtures; no test execution locally. + +Fragment disposition: this unit does not concatenate split tokens. #3661 contains no fragment association or representation evidence. The runtime's existing plaintext-in-encrypted-slot compatibility must remain. Add end-to-end regression coverage for a consecutive encrypted run whose exact concatenation is structurally one Fernet token: classify that narrowly as unreadable and refuse without recovery, while ordinary plaintext slots still normalize. If no sound discriminator is found, retain the issue residual explicitly; never claim full #3661 closure from whole-token support. + + +Concrete replacement contract: + +```ts +// AgentEnvelope +// - encryptedIndex: number; ciphertext: string; +// + encryptedStartIndex: number; ciphertexts: readonly string[]; +// + inputSnapshot: string; +// Parser: collect {index, token} only when token list has exactly one member +// and that member === raw encrypted_content. Reject missing header, +// >32 entries, >2 MiB aggregate, and nonconsecutive indexes. Capture +// JSON.stringify(item) at admission after all identity checks. +// Cache replaces .update(envelope.ciphertext) with +.update(JSON.stringify(envelope.ciphertexts)) +// Fixed recovery endpoint content replaces its single encrypted part with +...envelope.ciphertexts.map(encrypted_content => ({ + type: "encrypted_content", encrypted_content, +})) +// Injection verifies original item bytes before touching content: +if (JSON.stringify(item) !== envelope.inputSnapshot) return false; +content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, + { type: "input_text", text: assignment }); +``` + +The snapshot is request-local and not logged/persisted. JSON request parsing is the input boundary, so getters/cycles are not supported client states. Tests use the existing Request/recovery public entrypoints, not exported parser internals. + +Reflection amendment: also MODIFY `src/server/responses/encrypted-payload.ts` only for the narrow multi-slot discriminator and MODIFY `tests/server/agent-task-recovery.test.ts` with `post()` integration assertions that recovery is not attempted and routed fetch is absent. Whole-token recovery tests remain at the recovery API. The discriminator runs before sanitization; for otherwise unreadable envelopes, matched fragments do not reach the routed provider. General malformed payload detection remains outside this claim. + +wp2 reflection synthesis: preserve only identified fragment objects during sanitization, not an entire content array. Independent plaintext slots still normalize. Fragment refusal applies only when no independent readable task text remains, retaining current mixed-content policy; mixed input is explicitly outside the refusal claim. All encrypted slots in a recovery envelope must be valid consecutive whole tokens, including malformed non-string slots (which refuse). MODIFY `tests/server/agent-task-recovery-security.test.ts`: replace formerly unsupported duplicate-whole-token fixture with a genuinely noncontiguous encrypted run; keep fragment and admission-negative coverage, add positive multipart regression separately. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/030_verification.md new file mode 100644 index 0000000000..cf5ec964fd --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_verification.md @@ -0,0 +1,9 @@ +# Final hosted verification and handoff + +Consumes published implementation heads. No product change is planned unless exact hosted failure or independent audit identifies a defect; then amend this design with the concrete source delta before repair. + +MODIFY this unit's cycle records with actual outcomes. MODIFY ignored `.tmp/v2/handoff.md` and NEW ignored `.tmp/v2/final-ci.json` with own branch/worktree/session, original dispositions, credit, carry PR URLs, exact heads, chain order if any, remaining issue acceptance, unresolved review/security judgments and local NOT RUN. + +Commands: `git diff --check` observes whitespace only. `gh pr view` observes live head/base/reviews. `gh run list --commit ` finds hosted runs; `gh run view --json headSha,status,conclusion,jobs,url` provides final evidence. Inspect `.github/workflows/ci.yml` or actual workflow source for full lane dispatch. Do not claim skipped/cancelled jobs passed. CI failure repairs are additional PABCD cycles when they form a separate work-phase. + +Before publish, inspect exact diff and original contributor commits; push only owned branches using `git push --no-verify`. Populate Summary/Verification/Checklist template honestly with NOT RUN local tests. No closure or merge. Capture remote PR head equality with local final SHA and final Cross-platform CI result. A source scan or receipt wrapper is not product test evidence. Independent review has a source SHA and limitations. Any live upstream canary absent remains explicit. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 24dd9dec16..c65933f0bb 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -204,6 +204,19 @@ Admission and retention are deliberately narrow: fail-closed error; client cancellation returns 499. Neither path forwards ciphertext to the routed provider. +Recovery accepts one consecutive run of up to 32 complete Fernet-shaped encrypted parts, with +at most 2 MiB of combined ciphertext. Parts retain their order and boundaries in one authenticated +request. Cache identity includes the sequence; the original input is revalidated before assignment +replacement. HTTP failures retain the existing bounded diagnostic reason and do not trigger an +internal retry. + +Split tokens are not reconstructed for recovery. A bounded run whose exact concatenation has +Fernet structure stays classified as ciphertext through plaintext-slot normalization. If the task +has no independent readable text, it fails closed without a recovery or routed-provider request. +Independent readable text retains the existing mixed-content policy. Other fragment representations +remain unsupported; this does not establish general token-split recovery or upstream multipart +fidelity. + ### Threat model This path assumes the local native Codex caller already holds a valid ChatGPT credential and that diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index a15a2563ca..8f44661d22 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -3,7 +3,7 @@ import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import type { OcxConfig } from "../../types"; import { boundedBodyDecodeFailure, readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; -import { structurallyValidFernetTokens } from "./encrypted-payload"; +import { MAX_AGENT_TASK_CIPHERTEXT_BYTES, MAX_AGENT_TASK_ENCRYPTED_PARTS, structurallyValidFernetTokens } from "./encrypted-payload"; import { cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, @@ -31,7 +31,6 @@ const CODEX_ORIGINATORS = new Set([ const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_TOKEN_ISSUERS = new Set(["https://auth.openai.com", "https://auth.openai.com/"]); const OPENAI_TOKEN_AUDIENCE = "https://api.openai.com/v1"; -const MAX_CIPHERTEXT_BYTES = 2 * 1024 * 1024; const MAX_ASSIGNMENT_BYTES = 2 * 1024 * 1024; const MAX_RECOVERY_RESPONSE_BYTES = 4 * 1024 * 1024; const CACHE_SCOPE_KEY = randomBytes(32); @@ -73,12 +72,13 @@ export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOpt interface AgentEnvelope { itemIndex: number; - encryptedIndex: number; + encryptedStartIndex: number; + inputSnapshot: string; headerText: string; messageType: "NEW_TASK" | "MESSAGE"; taskName: string; sender: string; - ciphertext: string; + ciphertexts: readonly string[]; author: string; recipient: string; } @@ -107,10 +107,9 @@ function findEnvelope(input: unknown): AgentEnvelope | null { let messageType: "NEW_TASK" | "MESSAGE" | null = null; let taskName: string | null = null; let sender: string | null = null; - let encryptedIndex = -1; - let ciphertext = ""; - let encryptedPartCount = 0; - let ciphertextCount = 0; + let encryptedStartIndex = -1; + const ciphertexts: string[] = []; + let ciphertextBytes = 0; for (let index = 0; index < content.length; index += 1) { const part = content[index] as { type?: unknown; text?: unknown; encrypted_content?: unknown } | null; @@ -132,13 +131,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null { sender = match[3]!; } } - if (part.type !== "encrypted_content" || typeof part.encrypted_content !== "string") continue; - encryptedPartCount += 1; - for (const token of structurallyValidFernetTokens(part.encrypted_content)) { - ciphertextCount += 1; - encryptedIndex = index; - ciphertext = token; - } + if (part.type !== "encrypted_content") continue; + if (typeof part.encrypted_content !== "string") return null; + ciphertextBytes += Buffer.byteLength(part.encrypted_content); + if (ciphertexts.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || ciphertextBytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) return null; + const tokens = structurallyValidFernetTokens(part.encrypted_content); + if (tokens.length !== 1 || tokens[0] !== part.encrypted_content) return null; + if (encryptedStartIndex < 0) encryptedStartIndex = index; + if (index !== encryptedStartIndex + ciphertexts.length) return null; + ciphertexts.push(part.encrypted_content); } if ( @@ -146,11 +147,8 @@ function findEnvelope(input: unknown): AgentEnvelope | null { || !messageType || !taskName || !sender - || encryptedIndex < 0 - || encryptedPartCount !== 1 - || ciphertextCount !== 1 - || (content[encryptedIndex] as { encrypted_content?: unknown }).encrypted_content !== ciphertext - || Buffer.byteLength(ciphertext) > MAX_CIPHERTEXT_BYTES + || encryptedStartIndex < 0 + || ciphertexts.length === 0 ) return null; const itemRecord = item as { author?: unknown; recipient?: unknown }; @@ -159,12 +157,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null { return { itemIndex, - encryptedIndex, + encryptedStartIndex, + inputSnapshot: JSON.stringify(item), headerText, messageType, taskName, sender, - ciphertext, + ciphertexts, author: itemRecord.author, recipient: itemRecord.recipient, }; @@ -197,14 +196,8 @@ function injectAssignment(input: unknown, envelope: AgentEnvelope, assignment: s if (!item || typeof item !== "object") return false; const content = (item as { content?: unknown }).content; if (!Array.isArray(content)) return false; - const part = content[envelope.encryptedIndex] as { type?: unknown; encrypted_content?: unknown } | undefined; - if ( - !part - || part.type !== "encrypted_content" - || part.encrypted_content !== envelope.ciphertext - ) return false; - - content[envelope.encryptedIndex] = { type: "input_text", text: assignment }; + if (JSON.stringify(item) !== envelope.inputSnapshot) return false; + content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, { type: "input_text", text: assignment }); const message = item as Record; message.type = "message"; message.role = "user"; @@ -313,7 +306,7 @@ function admittedRecovery( .update("\0") .update(envelope.sender) .update("\0") - .update(envelope.ciphertext) + .update(JSON.stringify(envelope.ciphertexts)) .digest("hex"); return { admitted: true, recovery: { envelope, admission, cacheKey } }; } @@ -343,7 +336,7 @@ function recoveryPayload(envelope: AgentEnvelope, model: string): string { recipient: envelope.recipient, content: [ { type: "input_text", text: envelope.headerText }, - { type: "encrypted_content", encrypted_content: envelope.ciphertext }, + ...envelope.ciphertexts.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), ], }], }); diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 3df72f769a..82d4b0514e 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -200,6 +200,45 @@ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW // envelope stripper below. export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi; +export const MAX_AGENT_TASK_ENCRYPTED_PARTS = 32; +export const MAX_AGENT_TASK_CIPHERTEXT_BYTES = 2 * 1024 * 1024; + +/** Detection only: joining fragments never authorizes recovery or proves authenticity. */ +function splitFernetParts(content: unknown[]): Set { + const protectedParts = new Set(); + let run: Array<{ part: object; text: string }> = []; + let bytes = 0; + let overLimit = false; + const finish = (): void => { + if (!overLimit && run.length > 1 + && run.every(({ text }) => !isStructurallyValidFernetToken(text)) + && isStructurallyValidFernetToken(run.map(({ text }) => text).join(""))) { + for (const { part } of run) protectedParts.add(part); + } + run = []; + bytes = 0; + overLimit = false; + }; + for (const part of content) { + if (!part || typeof part !== "object" || (part as { type?: unknown }).type !== "encrypted_content" + || typeof (part as { encrypted_content?: unknown }).encrypted_content !== "string") { + finish(); + continue; + } + if (overLimit) continue; + const text = (part as { encrypted_content: string }).encrypted_content; + bytes += Buffer.byteLength(text); + if (run.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || bytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) { + overLimit = true; + run = []; + continue; + } + run.push({ part, text }); + } + finish(); + return protectedParts; +} + export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { if (!Array.isArray(input)) return false; @@ -222,7 +261,8 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { const content = (item as { content?: unknown }).content; if (!Array.isArray(content)) return false; - let hasFernetTask = false; + const fragmentParts = splitFernetParts(content); + let hasFernetTask = fragmentParts.size > 0; const readableParts: string[] = []; for (const part of content) { if (!part || typeof part !== "object") continue; @@ -238,6 +278,7 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { continue; } + if (fragmentParts.has(part)) continue; const runs = fernetTokenRuns(record.encrypted_content); if (runs.length > 0) hasFernetTask = true; readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs)); @@ -282,6 +323,7 @@ export function hasEncryptedContentPart(content: unknown): boolean { export function sanitizeEncryptedContentInPlace(input: unknown): number { if (!Array.isArray(input)) return 0; let rewritten = 0; + const protectedFragments = new WeakSet(); type VisitFrame = | { kind: "visit"; node: unknown } | { kind: "array"; node: unknown[]; index: number } @@ -293,6 +335,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number { const frame = stack.pop()!; if (frame.kind === "visit") { if (Array.isArray(frame.node)) { + for (const part of splitFernetParts(frame.node)) protectedFragments.add(part); stack.push({ kind: "array", node: frame.node, index: 0 }); } else if (frame.node && typeof frame.node === "object") { stack.push({ kind: "object", values: Object.values(frame.node), index: 0 }); @@ -309,7 +352,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number { && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string" ) { const payload = (child as { encrypted_content: string }).encrypted_content; - if (!looksLikeBackendCiphertext(payload)) { + if (!protectedFragments.has(child) && !looksLikeBackendCiphertext(payload)) { const parts = encryptedSlotParts(payload); frame.node.splice(frame.index, 1, ...parts); rewritten += 1; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 075037c14d..82a5afec2e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -60,3 +60,5 @@ so the schema is not something a user can fix from configuration (issue #2673). Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/catalog.md b/structure/catalog.md index 91d7734848..ab355b1ba7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -268,3 +268,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4c86504a08..4c0b045a10 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -79,3 +79,5 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 952f6ab06f..9ad5e11a0a 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -73,3 +73,5 @@ conflicts with `modelSupportsReasoningSummaries: false` for the same model. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 5fbc419557..338e845b33 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -93,3 +93,5 @@ falling back to OpenCodex guesses, and the integration does not write the remove Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a860a78fc2..df97370b21 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -515,3 +515,5 @@ survives availability drift, while complete/native custom orders await explicit Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..93ebef2741 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e4d2b41958..84c8bd08a7 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -136,3 +136,5 @@ client responsibilities. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 22105b20c8..7e378669d0 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -53,3 +53,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 5f67121e3e..a2314b4d34 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -194,3 +194,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..a884df4240 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,17 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +## Multipart encrypted task recovery + +`src/server/responses/agent-task-recovery.ts` admits at most 32 consecutive, individually complete +Fernet-shaped parts with a combined 2 MiB ciphertext limit. Every encrypted slot must belong to +that run. The existing credential admission precedes cache access; the cache key includes an +unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment +replacement compares the complete original item snapshot before splicing the run. Recovery output +is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added. + +`src/server/responses/encrypted-payload.ts` uses bounded concatenation only to recognize otherwise +unreadable split-token shapes. The sanitizer preserves just those fragment objects and continues +normalizing independent plaintext slots. Detection never authorizes reconstruction or recovery; +other fragment layouts and mixed readable content retain their documented residual boundaries. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 66f80525ad..4982d5b09d 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -61,3 +61,5 @@ executor contract. Main-request migration must not treat that branch as fixed-tr Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9903b7c18b..708606f5b2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -517,3 +517,5 @@ not retried. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index f90334d5b4..eb24afa52e 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -193,3 +193,5 @@ frame rather than always emitting `response.completed`. If the response status i Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/tests/server/agent-task-recovery-security.test.ts b/tests/server/agent-task-recovery-security.test.ts index d44c155dfb..c7a5ede55d 100644 --- a/tests/server/agent-task-recovery-security.test.ts +++ b/tests/server/agent-task-recovery-security.test.ts @@ -38,7 +38,7 @@ describe("agent task recovery security", () => { const header = { type: "input_text", text: ROUTING_ENVELOPE }; const encrypted = { type: "encrypted_content", encrypted_content: FERNET_TASK }; const inputs = [ - agentMessage([header, encrypted, encrypted]), + agentMessage([header, encrypted, { type: "input_text", text: "" }, encrypted]), agentMessage([header, { ...encrypted, encrypted_content: FERNET_TASK.slice(0, 50) }, { ...encrypted, encrypted_content: FERNET_TASK.slice(50) }]), agentMessage([{ ...header, text: ROUTING_ENVELOPE.replace("NEW_TASK", "new_task") }, encrypted]), @@ -339,7 +339,7 @@ describe("agent task recovery security", () => { throw new Error("recovery must stay unreachable"); }) as typeof fetch; const ambiguous = encryptedInput() as Array<{ content: Array> }>; - ambiguous[0]!.content.push({ type: "encrypted_content", encrypted_content: FERNET_TASK }); + ambiguous[0]!.content.push({ type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: FERNET_TASK }); const response = await post( routedConfig(), diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 939fe189eb..3be8b90432 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1,3 +1,4 @@ +import { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "../../src/server/responses/encrypted-payload"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../../src/server"; @@ -980,3 +981,116 @@ describe("mid-thread encrypted agent task recovery (#4089)", () => { expect(raw).not.toContain(FERNET_TASK); }); }); + + +describe("bounded multipart encrypted task recovery", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + const multipart = (tokens: string[] = [FERNET_TASK, SECOND_FERNET_TASK]) => agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE }, + ...tokens.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), + ]); + + test.each(["NEW_TASK", "MESSAGE"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { + let sends = 0; + const sent: Array<{ input: Array<{ content: Array<{ encrypted_content?: string }> }> }> = []; + globalThis.fetch = (async (_url, init) => { + sends++; + sent.push(JSON.parse(String(init?.body))); + return new Response(recoverySse("Complete multipart assignment.")); + }) as typeof fetch; + const input = () => { + const value = multipart(); + const item = value[0] as { content: Array> }; + item.content[0]!.text = ROUTING_ENVELOPE.replace("NEW_TASK", messageType); + return value; + }; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const current = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, current, {}, routedConfig())).toEqual({ recovered: true }); + expect(sent[0]!.input[0]!.content.slice(1).map(part => part.encrypted_content)).toEqual([FERNET_TASK, SECOND_FERNET_TASK]); + expect(current).toEqual([{ type: "message", role: "user", content: [ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, + { type: "input_text", text: "Complete multipart assignment." }, + ] }]); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(1); + const reversed = input(); + (reversed[0] as { content: unknown[] }).content.splice(1, 2, + { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }); + expect(restoreCachedEncryptedAgentTasks(req, reversed, routedConfig())).toBe(0); + expect(await recoverEncryptedAgentTaskWithResult(req, reversed, {}, routedConfig())).toEqual({ recovered: true }); + expect(sends).toBe(2); + discardEncryptedAgentTaskRecovery(req, input(), routedConfig()); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); + }); + + test("refuses malformed slots, nonconsecutive runs, and count/byte overflow without a fetch", async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return new Response(recoverySse("must not run")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const tooLargeRaw = Buffer.alloc(57 + 16 * 131072, 0x5a); + tooLargeRaw[0] = 0x80; + const token = tooLargeRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); + const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), + agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "encrypted_content", encrypted_content: 123 }]), + agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }]), + ]; + for (const input of cases) { + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(input).toEqual(before); + } + expect(sends).toBe(0); + }); + + test("revalidates full input identity after asynchronous recovery", async () => { + let release!: (response: Response) => void; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + globalThis.fetch = (() => { started(); return new Promise(resolve => { release = resolve; }); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = multipart(); + const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig()); + await ready; + (input[0] as { author: string }).author = "changed-author"; + release(new Response(recoverySse("Must not replace changed task."))); + expect(await pending).toEqual({ recovered: false, reason: "input_changed" }); + expect((input[0] as { type: string }).type).toBe("agent_message"); + expect(restoreCachedEncryptedAgentTasks(req, multipart(), routedConfig())).toBe(0); + }); + + test("split tokens remain unreadable through sanitization and never trigger recovery", async () => { + const input = multipart([FERNET_TASK.slice(0, 50), FERNET_TASK.slice(50)]); + const before = structuredClone(input); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + expect(sanitizeEncryptedContentInPlace(input)).toBe(0); + expect(input).toEqual(before); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + let sends = 0; + globalThis.fetch = (async () => { sends++; return providerResponse(); }) as typeof fetch; + const response = await post(routedConfig(), "xai/grok-4.5", input, codexHeaders()); + expect(response.status).toBe(400); + expect(await response.text()).toContain("unreadable_encrypted_agent_task"); + expect(sends).toBe(0); + }); + + test("identified fragments do not prevent independent plaintext-slot normalization", () => { + const input = multipart([FERNET_TASK.slice(0, 50), FERNET_TASK.slice(50)]); + const content = (input[0] as { content: Array> }).content; + content.push({ type: "input_text", text: "Readable task." }, { type: "encrypted_content", encrypted_content: "Independent plaintext." }); + const fragments = structuredClone(content.slice(1, 3)); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + expect(sanitizeEncryptedContentInPlace(input)).toBe(1); + expect(content.slice(1, 3)).toEqual(fragments); + expect(content.at(-1)).toEqual({ type: "input_text", text: "Independent plaintext." }); + }); + + test("multipart backend 503 is still one attempt with bounded diagnostics", async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return new Response("private failure", { status: 503 }); }) as typeof fetch; + const result = await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses", { headers: codexHeaders() }), multipart(), {}, routedConfig()); + expect(result).toEqual({ recovered: false, reason: "recovery_http_rejected" }); + expect(sends).toBe(1); + }); +}); From 36625c78be4ca0ff5a94e145cdffe52a5ba9092d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:36 +0900 Subject: [PATCH 11/68] test(cursor): activate remint guards after first overflow --- tests/providers/cursor/cursor-adapter.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 82049b83f4..1f42b697dc 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1017,9 +1017,12 @@ describe("Cursor overflow conversation remint", () => { stream: false, options: {}, _cursorConversationId: "cursor_overflow_tool", + _clientThreadId: "overflow-tool-result", _cursorIdentityScope: "acct-overflow-remint", }; + await adapter.runTurn?.(overflowTurnBody("overflow-tool-result"), { headers: new Headers() }, () => {}); + attempts = 0; await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); expect(attempts).toBe(1); }); @@ -1057,6 +1060,7 @@ describe("Cursor overflow conversation remint", () => { test("does not overflow-remint after non-heartbeat output was emitted", async () => { clearCursorOverflowRemintForTests(); let attempts = 0; + let emitPartial = false; const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token", @@ -1064,7 +1068,7 @@ describe("Cursor overflow conversation remint", () => { createTransport: () => ({ async *run() { attempts += 1; - yield { type: "text", text: "partial" } satisfies CursorServerMessage; + if (emitPartial) yield { type: "text", text: "partial" } satisfies CursorServerMessage; throw bareOverflowError(); }, writeClient() {}, @@ -1072,6 +1076,9 @@ describe("Cursor overflow conversation remint", () => { }); const body = overflowTurnBody("overflow-after-output"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + emitPartial = true; const events: AdapterEvent[] = []; await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); @@ -1113,9 +1120,11 @@ describe("Cursor overflow accounting across requests", () => { for (let remint = 0; remint < 3; remint++) { failNext = true; const before = attempts; + const priorConversation = seen[seen.length - 1]; const events: AdapterEvent[] = []; await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); expect(attempts - before).toBe(2); + expect(seen[seen.length - 2]).toBe(priorConversation); expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); expect(events.some(event => event.type === "done")).toBe(true); } From a7b41aad4e3a6d632803664ffb94d6d3443b7d1c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:03:15 +0900 Subject: [PATCH 12/68] test: cover multipart boundary and input mutation cases --- tests/server/agent-task-recovery.test.ts | 27 +++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 3be8b90432..faf117f1a7 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1025,6 +1025,21 @@ describe("bounded multipart encrypted task recovery", () => { expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); }); + test("accepts exactly 32 whole parts without deduplicating ciphertext", async () => { + let sends = 0; + let forwarded: string[] = []; + globalThis.fetch = (async (_url, init) => { + sends++; + const body = JSON.parse(String(init?.body)); + forwarded = body.input[0].content.slice(1).map((part: { encrypted_content: string }) => part.encrypted_content); + return new Response(recoverySse("All repeated parts retained.")); + }) as typeof fetch; + const tokens = Array.from({ length: 32 }, () => FERNET_TASK); + expect(await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses", { headers: codexHeaders() }), multipart(tokens), {}, routedConfig())).toEqual({ recovered: true }); + expect(forwarded).toEqual(tokens); + expect(sends).toBe(1); + }); + test("refuses malformed slots, nonconsecutive runs, and count/byte overflow without a fetch", async () => { let sends = 0; globalThis.fetch = (async () => { sends++; return new Response(recoverySse("must not run")); }) as typeof fetch; @@ -1032,7 +1047,10 @@ describe("bounded multipart encrypted task recovery", () => { const tooLargeRaw = Buffer.alloc(57 + 16 * 131072, 0x5a); tooLargeRaw[0] = 0x80; const token = tooLargeRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); - const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), + const boundedRaw = Buffer.alloc(57 + 16 * 50000, 0x5a); + boundedRaw[0] = 0x80; + const boundedToken = boundedRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); + const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), multipart([boundedToken, boundedToken]), agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "encrypted_content", encrypted_content: 123 }]), agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }]), ]; @@ -1044,7 +1062,7 @@ describe("bounded multipart encrypted task recovery", () => { expect(sends).toBe(0); }); - test("revalidates full input identity after asynchronous recovery", async () => { + test.each(["author", "header", "later-token"] as const)("revalidates %s after asynchronous recovery", async mutation => { let release!: (response: Response) => void; let started!: () => void; const ready = new Promise(resolve => { started = resolve; }); @@ -1053,7 +1071,10 @@ describe("bounded multipart encrypted task recovery", () => { const input = multipart(); const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig()); await ready; - (input[0] as { author: string }).author = "changed-author"; + const item = input[0] as { author: string; content: Array> }; + if (mutation === "author") item.author = "changed-author"; + else if (mutation === "header") item.content[0]!.text = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); + else item.content[2]!.encrypted_content = FERNET_TASK; release(new Response(recoverySse("Must not replace changed task."))); expect(await pending).toEqual({ recovered: false, reason: "input_changed" }); expect((input[0] as { type: string }).type).toBe("agent_message"); From 321b9b1cd1e9031732f46893d6cbbc0c774cfca1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:07:33 +0900 Subject: [PATCH 13/68] fix(live): validate sideband upstream before client upgrade Co-authored-by: Kosta Milovanovic --- .../content/docs/reference/proxy-formats.md | 8 + src/server/index.ts | 368 ++++++++++++- src/server/ws-bridge.ts | 21 + structure/runtime.md | 4 + tests/server/server-live.test.ts | 493 +++++++++++++++++- 5 files changed, 865 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..dedd5b83ec 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Live sideband connection failures + +The proxy completes the upstream live sideband handshake before accepting the client +WebSocket. An upstream rejection fails the upgrade with 502; a ten-second handshake timeout +returns 504. Bun does not expose the exact upstream handshake status, so an upstream 404/410 +cannot currently be forwarded precisely. A successful connection preserves the initial session +frames in order. This handshake policy is separate from the Responses WebSocket transport. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..e0af64d255 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,8 @@ import { buildResponsesWsData, sendResponseToWebSocket, sendTextFrame, + type LiveSidebandUpstreamFailure, + type LiveSidebandUpstreamHandoff, type WsData, } from "./ws-bridge"; import type { Server, ServerWebSocket } from "bun"; @@ -319,6 +321,29 @@ function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmissio const LIVE_SIDEBAND_PENDING_MAX = 32; const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024; const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; +/** + * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101 + * must fail the client upgrade promptly rather than hold it open indefinitely. + */ +export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000; + +/** + * Outcome of the upstream sideband handshake performed before the client upgrade. + * + * `ok: false` carries the HTTP status the client upgrade must fail with. Only an + * upgrade failure reaches codex-rs as a connect error, and only a connect error + * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err` + * arm always breaks). A 101 followed by a close is instead read as `TransportLost` + * and retried forever against the same, permanently dead call id. + */ +export type LiveSidebandUpstreamOpenResult = + | { + ok: true; + socket: WebSocket; + /** Owns capture and terminal events until the downstream relay attaches. */ + handoff: LiveSidebandUpstreamHandoff; + } + | { ok: false; status: number; code: string; message: string; socket?: WebSocket }; export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean { return frameBytes > MAX_WS_FRAME_BYTES; @@ -416,6 +441,48 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); } +function closeLiveSidebandBeforeUpgrade( + upstream: WebSocket, + release: () => void, + code = 1000, + reason = "", +): void { + // There is no downstream socket to own this transport yet. Mirror + // closeLiveSideband's bounded close contract directly: release only after a + // close event or an observed CLOSED state, never merely after requesting close. + let released = false; + let fallback: ReturnType | undefined; + const releaseOnce = (): void => { + if (released) return; + released = true; + if (fallback !== undefined) clearTimeout(fallback); + release(); + }; + upstream.addEventListener("close", releaseOnce, { once: true }); + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + fallback = setTimeout(() => { + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + try { + upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until CLOSED is observed */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); + }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); + try { + upstream.close(code, reason); + } catch { + /* the bounded fallback retries without releasing ownership */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); +} + function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { if (ws.data.liveClosing) return; ws.data.liveClosing = true; @@ -448,29 +515,243 @@ function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = "" } } -function attachLiveSidebandUpstream( +/** + * Dial the upstream sideband and report whether its handshake reached 101. + * + * Bun's client WebSocket does not surface the upstream handshake status, so the + * result is "opened" or "failed" and nothing finer. That is sufficient for the + * property this exists to guarantee: the client is never told the relay is live + * when it is not. Frames the upstream sends before the client socket exists are + * captured and handed back by `drain`, because a session preamble such as + * `session.created` arrives immediately after the upstream opens. + */ +export function openLiveSidebandUpstream( + url: string, + headers: Record, + createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => ( + new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[]) + ), + timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + signal?: AbortSignal, +): Promise { + return new Promise(resolve => { + let socket: WebSocket; + try { + socket = createWebSocket(url, headers); + } catch { + resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" }); + return; + } + + const buffered: Array = []; + let bufferedBytes = 0; + let capturing = true; + let settled = false; + let terminalFailure: LiveSidebandUpstreamFailure | undefined; + let removeAbortListener = (): void => {}; + + const finish = (result: LiveSidebandUpstreamOpenResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + removeAbortListener(); + resolve(result); + }; + const timer = setTimeout(() => { + const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" }; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(); + } catch { + /* ignore */ + } + }, timeoutMs); + + const failCapture = (failure: LiveSidebandUpstreamFailure): void => { + if (!capturing || terminalFailure) return; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(1009, "sideband preamble overflow"); + } catch { + /* the terminal failure is already retained for the downstream handoff */ + } + }; + const handoff: LiveSidebandUpstreamHandoff = { + failure: () => terminalFailure, + take: () => { + capturing = false; + if (terminalFailure) return { ok: false, failure: terminalFailure }; + const frames = buffered.slice(); + buffered.length = 0; + bufferedBytes = 0; + return { ok: true, frames }; + }, + }; + + socket.addEventListener("message", event => { + if (!capturing) return; + const frameBytes = webSocketFrameBytes(event.data); + if (exceedsLiveSidebandFrameByteLimit(frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" }); + return; + } + if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" }); + return; + } + if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" }); + return; + } + if (typeof event.data === "string") buffered.push(event.data); + else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data))); + else if (ArrayBuffer.isView(event.data)) { + buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength))); + } else return; + bufferedBytes += frameBytes; + }); + socket.addEventListener("open", () => { + finish({ + ok: true, + socket, + handoff, + }); + }); + socket.addEventListener("error", () => { + const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the terminal failure is already retained */ + } + }); + socket.addEventListener("close", event => { + const failure = { + status: 502, + code: "upstream_error", + message: `voice upstream closed before opening (code ${event.code})`, + closeCode: event.code, + closeReason: event.reason, + }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + }); + const abortOpen = (): void => { + const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the cancelled join no longer owns the socket */ + } + }; + if (signal) { + signal.addEventListener("abort", abortOpen, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", abortOpen); + if (signal.aborted) abortOpen(); + } + }); +} + +export function attachLiveSidebandUpstream( ws: ServerWebSocket, createWebSocket: LiveSidebandWebSocketFactory = (url, headers) => ( new WebSocket(url, { headers } as unknown as string[]) ), ): void { - const url = ws.data.liveUpstreamUrl; - if (!url) { - closeLiveSideband(ws, 1011, "missing upstream"); - return; - } + // A socket carried in from the upgrade handler already completed its handshake + // before the client was told 101. Reuse it rather than dialing a second upstream. + const preOpened = ws.data.liveUpstream; let upstream: WebSocket; - try { - // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. - upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); - } catch { - closeLiveSideband(ws, 1011, "upstream connect failed"); - return; + if (preOpened) { + upstream = preOpened; + } else { + const url = ws.data.liveUpstreamUrl; + if (!url) { + closeLiveSideband(ws, 1011, "missing upstream"); + return; + } + try { + // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. + upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); + } catch { + closeLiveSideband(ws, 1011, "upstream connect failed"); + return; + } } ws.data.liveUpstream = upstream; ws.data.liveClosing = false; ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); + upstream.addEventListener("close", (event) => { + if (ws.data.liveUpstream !== upstream) return; + ws.data.liveClosing = true; + finalizeLiveSideband(ws, upstream); + try { + ws.close(event.code || 1000, event.reason || ""); + } catch { + /* ignore */ + } + }); + upstream.addEventListener("error", () => { + if (ws.data.liveUpstream !== upstream) return; + closeLiveSideband(ws, 1011, "upstream error"); + }); + + if (preOpened) { + // The upstream opened before this socket existed, so its `open` event has already + // fired and the listener below will never run. Its early frames were captured for + // us; forward the capture now rather than dropping the session preamble. + const handoff = ws.data.liveUpstreamHandoff; + ws.data.liveUpstreamHandoff = undefined; + const takeover = handoff?.take(); + if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) { + const failure = takeover && !takeover.ok ? takeover.failure : undefined; + closeLiveSideband( + ws, + failure?.closeCode ?? 1011, + failure?.closeReason ?? "upstream closed before relay attachment", + ); + return; + } + ws.data.liveOpened = true; + for (const frame of takeover.frames) { + try { + // Mirror the live message listener exactly: same ceiling, same diagnostic + // record. These frames are upstream-to-client like any other. + if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("u2c", frame); + ws.send(frame); + } catch { + closeLiveSideband(ws, 1011, "client send failed"); + return; + } + } + } + upstream.addEventListener("open", () => { if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; ws.data.liveOpened = true; @@ -503,20 +784,6 @@ function attachLiveSidebandUpstream( closeLiveSideband(ws, 1011, "client send failed"); } }); - upstream.addEventListener("close", (event) => { - if (ws.data.liveUpstream !== upstream) return; - ws.data.liveClosing = true; - finalizeLiveSideband(ws, upstream); - try { - ws.close(event.code || 1000, event.reason || ""); - } catch { - /* ignore */ - } - }); - upstream.addEventListener("error", () => { - if (ws.data.liveUpstream !== upstream) return; - closeLiveSideband(ws, 1011, "upstream error"); - }); } // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the @@ -2185,19 +2452,64 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server turnAdmissionLease.release()); + } else { + turnAdmissionLease.release(); + } + addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status); + console.error(`[live] sideband upstream handshake failed: ${upstreamHandshake.message}`); + return withCors( + formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message), + req, + policy, + ); + } + const handoffFailure = upstreamHandshake.handoff.failure(); + if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) { + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); + const failure = handoffFailure ?? { + status: 502, + code: "upstream_error", + message: "voice upstream closed before client upgrade", + }; + addFinalRequestLog(requestId, start, logCtx, failure.status); + return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy); + } addFinalRequestLog(requestId, start, logCtx, 101); if (requestServer.upgrade(req, { data: { kind: "live-sideband", + liveUpstream: upstreamHandshake.socket, liveUpstreamUrl: resolved.upstreamWsUrl, liveUpstreamHeaders: resolved.headers, + liveUpstreamHandoff: upstreamHandshake.handoff, livePending: [], livePendingBytes: 0, - liveOpened: false, + liveOpened: true, liveTurnAdmissionLease: turnAdmissionLease, } satisfies WsData, })) return undefined as unknown as Response; - turnAdmissionLease.release(); + // The upgrade was refused after the upstream had already opened; drop it. + try { + upstreamHandshake.handoff.take(); + } catch { + /* ignore */ + } + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); } diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 5777b45a10..7b4e4c37f8 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -39,6 +39,8 @@ export interface WsData { /** Total encoded bytes retained in livePending while the upstream connects. */ livePendingBytes?: number; liveOpened?: boolean; + /** Owns captured frames and terminal state until the downstream relay attaches. */ + liveUpstreamHandoff?: LiveSidebandUpstreamHandoff; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; /** Schedules one bounded close retry without surrendering native-main ownership. */ @@ -48,6 +50,25 @@ export interface WsData { admissionLease?: AdmissionReservation>; } +export interface LiveSidebandUpstreamFailure { + status: number; + code: string; + message: string; + closeCode?: number; + closeReason?: string; +} + +export type LiveSidebandUpstreamTakeover = + | { ok: true; frames: Array } + | { ok: false; failure: LiveSidebandUpstreamFailure }; + +export interface LiveSidebandUpstreamHandoff { + /** Observe failure before the downstream upgrade without ending capture. */ + failure(): LiveSidebandUpstreamFailure | undefined; + /** Atomically ends capture and transfers buffered frames or terminal state. */ + take(): LiveSidebandUpstreamTakeover; +} + /** * Build the Responses WebSocket upgrade payload. * diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..19f7dc3c95 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,7 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +### Live sideband handshake + +`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index f6d4da8916..441d6bf01a 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -15,13 +15,20 @@ import { type ReadinessGate, } from "../../src/server/readiness"; import { + attachLiveSidebandUpstream, enqueueLiveSidebandPendingFrame, exceedsLiveSidebandFrameByteLimit, exceedsLiveSidebandPendingByteLimit, MAX_WS_FRAME_BYTES, + openLiveSidebandUpstream, startServer, } from "../../src/server"; -import { beginShutdownDrain, isDraining, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { + activeRegistryMetrics, + beginShutdownDrain, + isDraining, + resetLifecycleDrainStateForTests, +} from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -1739,3 +1746,487 @@ describe("GET /readyz while draining", () => { } }); }); + +/** + * A sideband join must not report 101 unless the upstream handshake actually + * succeeded. A 101 followed by a close is read by codex-rs as `TransportLost`, + * which it recovers from by rejoining the same call id indefinitely; a failed + * upgrade is a connect error instead, and that is the only outcome that ends the + * loop. These cases pin the handshake result and its client-visible consequence. + */ +class FakeUpstreamSocket { + private readonly listeners = new Map void>>(); + closed = false; + closeCalls = 0; + closeMode: "closed" | "closing" | "closing-then-close" = "closed"; + readyState = WebSocket.CONNECTING; + + addEventListener(type: string, listener: (event: { code?: number; data?: unknown; reason?: string }) => void): void { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + } + + emit(type: string, event: { code?: number; data?: unknown; reason?: string } = {}): void { + if (type === "open") this.readyState = WebSocket.OPEN; + if (type === "close") this.readyState = WebSocket.CLOSED; + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + close(code = 1000, reason = ""): void { + this.closed = true; + this.closeCalls += 1; + if (this.closeMode === "closing") { + this.readyState = WebSocket.CLOSING; + return; + } + if (this.closeMode === "closing-then-close") this.readyState = WebSocket.CLOSING; + this.emit("close", { code, reason }); + } +} + +function fakeSidebandClient( + upstream: FakeUpstreamSocket, + handoff: { + failure(): { status: number; code: string; message: string; closeCode?: number; closeReason?: string } | undefined; + take(): { ok: true; frames: Array } | { + ok: false; + failure: { status: number; code: string; message: string; closeCode?: number; closeReason?: string }; + }; + }, + send: (frame: string | Buffer) => void = () => {}, +) { + let releases = 0; + const ws = { + data: { + kind: "live-sideband" as const, + liveUpstream: upstream as unknown as WebSocket, + liveUpstreamHandoff: handoff, + liveOpened: true, + liveTurnAdmissionLease: { + release: () => { releases += 1; }, + }, + }, + readyState: WebSocket.OPEN, + close: () => {}, + send, + }; + return { ws, releases: () => releases }; +} + +describe("attachLiveSidebandUpstream ownership", () => { + test("transfers the actual captured preamble before subsequent live frames", async () => { + const upstream = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/fixture", {}, () => upstream as unknown as WebSocket); + upstream.emit("open"); + upstream.emit("message", { data: "first" }); + upstream.emit("message", { data: new Uint8Array([2]) }); + const result = await pending; + if (!result.ok) throw new Error("expected open handshake"); + const sent: Array = []; + const client = fakeSidebandClient(upstream, result.handoff, frame => { sent.push(frame); }); + attachLiveSidebandUpstream(client.ws as never); + upstream.emit("message", { data: "third" }); + expect(sent).toEqual(["first", Buffer.from([2]), "third"]); + upstream.emit("close", { code: 1000 }); + expect(client.releases()).toBe(1); + }); + + test("retains admission through a failed takeover until a CLOSING upstream actually closes", async () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing"; + const client = fakeSidebandClient(upstream, { + failure: () => undefined, + take: () => ({ + ok: false, + failure: { status: 502, code: "upstream_error", message: "closed", closeCode: 1008 }, + }), + }); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(client.releases()).toBe(0); + await Bun.sleep(1_100); + expect(upstream.closeCalls).toBe(2); + expect(client.releases()).toBe(0); + upstream.emit("close", { code: 1008, reason: "call ended" }); + expect(client.releases()).toBe(1); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(client.releases()).toBe(1); + }); + + test("registers close ownership before forwarding a pre-opened preamble", () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing-then-close"; + const client = fakeSidebandClient( + upstream, + { + failure: () => undefined, + take: () => ({ ok: true, frames: ["session.created"] }), + }, + () => { throw new Error("downstream send failed"); }, + ); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.closeCalls).toBe(1); + expect(upstream.readyState).toBe(WebSocket.CLOSED); + expect(client.releases()).toBe(1); + }); +}); + +describe("openLiveSidebandUpstream", () => { + test("drains the preamble captured before the client socket exists", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + // The session preamble arrives the moment the upstream opens, before the client. + socket.emit("message", { data: "session.created" }); + socket.emit("message", { data: new Uint8Array([1, 2, 3]) }); + socket.emit("open", {}); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected an open upstream"); + expect(result.socket).toBe(socket); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(true); + if (!takeover.ok) throw new Error("expected a successful handoff"); + const drained = takeover.frames; + expect(drained[0]).toBe("session.created"); + expect(Buffer.isBuffer(drained[1])).toBe(true); + expect(drained[1]).toEqual(Buffer.from([1, 2, 3])); + // Drain is one-shot: the relay owns capture from here on. + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + socket.emit("message", { data: "after-drain" }); + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + }); + + test("fails explicitly before copying an aggregate preamble overflow", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + const retained = new Uint8Array(1024 * 1024); + socket.emit("message", { data: retained }); + const rejectedView = new Uint8Array(retained.buffer, 0, 1); + socket.emit("message", { data: rejectedView }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("fails explicitly when the preamble frame-count limit is exceeded", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + for (let index = 0; index < 33; index += 1) socket.emit("message", { data: String(index) }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("preserves an open-then-close terminal event until relay handoff", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("open", {}); + socket.emit("close", { code: 1008 }); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected the completed opening handshake"); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(false); + if (takeover.ok) throw new Error("expected the terminal handoff"); + expect(takeover.failure.closeCode).toBe(1008); + }); + + test("reports failure when the upstream rejects the handshake", async () => { + const socket = new FakeUpstreamSocket(); + socket.closeMode = "closing"; + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("error", {}); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBe(socket); + expect(socket.readyState).toBe(WebSocket.CLOSING); + }); + + test("reports failure when the upstream closes before opening", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("close", { code: 1006 }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + }); + + test("cancels a pending join and closes its upstream socket", async () => { + const socket = new FakeUpstreamSocket(); + const controller = new AbortController(); + const pending = openLiveSidebandUpstream( + "ws://upstream/v1/live/x", + {}, + () => socket as unknown as WebSocket, + 1_000, + controller.signal, + ); + controller.abort(); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a cancelled handshake"); + expect(result.code).toBe("request_cancelled"); + expect(socket.closed).toBe(true); + expect(result.socket).toBe(socket); + }); + + test("times out and drops the socket when the upstream never opens", async () => { + const socket = new FakeUpstreamSocket(); + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 20); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a timeout"); + expect(result.status).toBe(504); + expect(socket.closed).toBe(true); + }); + + test("reports failure when the upstream socket cannot be constructed", async () => { + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => { + throw new Error("connect refused"); + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBeUndefined(); + }); +}); + +test("a failed pre-upgrade handshake retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => upstream.emit("error", {})); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handshake_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handshake_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed upgrade")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1006, reason: "closed after handshake failure" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1006, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a failed pre-upgrade handoff retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("error", {}); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handoff_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handoff_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed handoff")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1008, reason: "closed after failed handoff" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a sideband join whose upstream handshake fails never opens the client socket", async () => { + // An upstream that refuses the upgrade: the shape OpenAI returns for a call id it + // no longer knows (`404 call_id_not_found`). + const upstream = Bun.serve({ + port: 0, + fetch(req) { + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return new Response(JSON.stringify({ error: { code: "call_id_not_found" } }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + saveConfig(forwardConfig()); + + const RealWebSocket = globalThis.WebSocket; + const upstreamPort = upstream.port; + globalThis.WebSocket = class extends RealWebSocket { + constructor(url: string | URL, protocols?: string | string[] | Record) { + const parsed = new URL(String(url)); + const target = parsed.hostname === "api.openai.com" + ? `ws://127.0.0.1:${upstreamPort}${parsed.pathname}${parsed.search}` + : String(url); + super(target, protocols as string[]); + } + } as typeof WebSocket; + + const server = startServer(0); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL(`/v1/realtime?call_id=rtc_dead_call`, server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new RealWebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_dead", + }, + } as unknown as string[]); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 15_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + // The relay never became live, so the client must not have been told it did. + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + globalThis.WebSocket = RealWebSocket; + await server.stop(true); + await upstream.stop(true); + } +}, { timeout: 20_000 }); + +test("an upstream that opens then closes before relay attachment refuses the client and releases admission", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("close", { code: 1008, reason: "call ended" }); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_closed_handoff", server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_closed_handoff", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); From 115e3475f48b249c7653e41f1ead98281d9b93eb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:12:15 +0900 Subject: [PATCH 14/68] docs: synchronize shared V2 roadmap and review outcomes --- devlog/_plan/260912_v2_contracts/000_plan.md | 9 ++++++++- .../260912_v2_contracts/030_native_identity.md | 13 +++++++++++++ .../{030_verification.md => 040_verification.md} | 0 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260912_v2_contracts/030_native_identity.md rename devlog/_plan/260912_v2_contracts/{030_verification.md => 040_verification.md} (100%) diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md index 5ba4c2022d..1646f58064 100644 --- a/devlog/_plan/260912_v2_contracts/000_plan.md +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -9,7 +9,8 @@ Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs | wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | | wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | | wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | -| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | +| wp3 | Restore exact native collaboration dispatch identities | [030](030_native_identity.md) | +| wp4 | Final cumulative hosted verification and durable handoff | [040](040_verification.md) | wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. @@ -24,3 +25,9 @@ wp0: P entered with own session binding; roadmap in progress. Product validation wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. + +wp1 D: plaintext implementation published as #4351, static review findings resolved; local tests NOT RUN and hosted proof deferred. +wp2 D: independent multipart implementation published as #4364; static security review PASS. Exact-count, multiplicity, aggregate-byte and mutation regression code added. Token-split reconstruction and live backend fidelity remain issue acceptance, not claimed solved. +wp3 D: source inspection of native Codex at 095da4b7e8b70b01afb5c6131ef926dcb8c0d85d required exact namespace/name restoration. Implementation at 7dc0bf4ea6 received independent static PASS. The earlier helper-only expectations did not establish native dispatch compatibility. Final hosted validation is wp4. + +Disposition: #4242/#4243 were rejected as-is after contract audit; #2496 is the credited adaptation source. #2495 remains open pending integration/retention approval and backend canary judgment. #3661 remains partial. The two carry PRs are independent dev-based siblings; no manual dependency chain or native stack was introduced. Public source/reference facts only are recorded here; detailed security audit material stays in ignored scratch. diff --git a/devlog/_plan/260912_v2_contracts/030_native_identity.md b/devlog/_plan/260912_v2_contracts/030_native_identity.md new file mode 100644 index 0000000000..14fa64a80c --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_native_identity.md @@ -0,0 +1,13 @@ +# Native plaintext tool identity correction + +Prior D: wp2 source and static audit complete, hosted acceptance pending. Final consumer tracing found a missing identity component; split correction from final hosted verification rather than accepting helper-only mock expectations. + +MODIFY `src/responses/plaintext-v2-agent-messages.ts`: private bare and qualified aliases in calls/selectors must restore both `namespace: "collaboration"` and the unqualified declared child name. Namespace-member declarations restore only their child name, without injecting a redundant namespace field. Foreign namespaces remain untouched. Add an explicit namespace-member traversal context so declarations and selectors are not conflated. + +Before: an unqualified `start_delegated_task` becomes bare `spawn_agent`, or a qualified private name becomes `collaboration__spawn_agent`. After: a call becomes `{namespace:"collaboration",name:"spawn_agent"}`, preserving encrypted_function_args. A declaration inside restored namespace has `{type:"function",name:"spawn_agent"}`. + +MODIFY `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts`, `tests/responses/ws-upstream.test.ts`: pin exact namespace+child identity for bare, dotted, double-underscore, JSON, SSE and WS restoration; assert a compatible namespace/name plus empty marker selects the documented native plaintext path. Keep foreign and opaque data negatives. + +MODIFY `structure/subagents.md`: canonical dispatch identity is namespace plus unqualified child name. Source authority: locally inspected upstream `protocol/src/tool_name.rs` constructor preserves name literally; with_default_namespace assigns functions to absent namespace. `core/src/tools/router.rs` direct_source requires collaboration plus exact spawn_agent/send_message/followup_task and empty marker. This is source evidence, not a live backend canary. + +No new settings or APIs; same request alias metadata and collision gates. Product checks remain hosted-only; local tests/build/typecheck/install NOT RUN. Independent design and A review precede code; final evidence remains wp4. This amendment adds work and does not remove any original acceptance requirement. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/040_verification.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/030_verification.md rename to devlog/_plan/260912_v2_contracts/040_verification.md From d3b3b6d525ec58b44ce5561fe6feebbdad38a4dc Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:15:57 +0900 Subject: [PATCH 15/68] feat(remote): integrate opt-in workspace dashboard and admission Carry #3458 dashboard and CLI wiring with explicit Hub opt-in, session-only mutations and awaited optional cleanup. Preserve current server and documentation owners; hosted preview and final cumulative CI remain pending. Co-authored-by: Ingwannu --- .../030_integration.md | 18 + docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/remote-hub.md | 4 +- .../content/docs/guides/remote-workspace.md | 189 +++++++++ docs-site/src/content/docs/reference/cli.md | 13 + .../content/docs/reference/management-api.md | 28 ++ gui/src/App.tsx | 4 + gui/src/app-routing.ts | 2 + gui/src/i18n/de.ts | 60 +++ gui/src/i18n/en.ts | 60 +++ gui/src/i18n/fr.ts | 60 +++ gui/src/i18n/ja.ts | 60 +++ gui/src/i18n/ko.ts | 60 +++ gui/src/i18n/ru.ts | 60 +++ gui/src/i18n/tr.ts | 60 +++ gui/src/i18n/zh-TW.ts | 60 +++ gui/src/i18n/zh.ts | 60 +++ gui/src/pages/RemoteWorkspace.tsx | 381 ++++++++++++++++++ gui/src/remote-workspace-command.ts | 18 + gui/src/styles-remote-workspace.css | 75 ++++ gui/src/styles.css | 1 + gui/tests/fr-localization.test.ts | 4 + gui/tests/locale-parity.test.ts | 2 + gui/tests/remote-workspace.test.tsx | 180 +++++++++ gui/tests/sidebar-rows.test.ts | 4 +- scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 47 ++- src/cli/capabilities.ts | 79 ++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 11 + src/remote-control/workspace-activation.ts | 9 + src/remote-control/workspace-sessions.ts | 5 + src/server/index.ts | 182 ++++++++- src/server/management-api.ts | 16 + src/server/management/context.ts | 15 + .../management/remote-workspace-routes.ts | 140 +++++++ src/server/management/route-registry.ts | 9 + src/server/ws-bridge.ts | 5 +- structure/INDEX.md | 2 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/manifest.json | 2 +- structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/remote-workspace.md | 10 +- structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-headless-parity.test.ts | 6 + .../remote-workspace-activation.test.ts | 44 ++ .../remote-workspace-management.test.ts | 195 +++++++++ tests/clients/remote-workspace-server.test.ts | 369 +++++++++++++++++ .../clients/remote-workspace-sessions.test.ts | 17 + tests/fixtures/test-layout-expected.json | 3 + .../loopback-listener-integration.test.ts | 2 +- 66 files changed, 2660 insertions(+), 14 deletions(-) create mode 100644 docs-site/src/content/docs/guides/remote-workspace.md create mode 100644 gui/src/pages/RemoteWorkspace.tsx create mode 100644 gui/src/remote-workspace-command.ts create mode 100644 gui/src/styles-remote-workspace.css create mode 100644 gui/tests/remote-workspace.test.tsx create mode 100644 src/remote-control/workspace-activation.ts create mode 100644 src/server/management/remote-workspace-routes.ts create mode 100644 tests/clients/remote-workspace-activation.test.ts create mode 100644 tests/clients/remote-workspace-management.test.ts create mode 100644 tests/clients/remote-workspace-server.test.ts diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md index 5c126f5987..ad6fcc94d3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/030_integration.md +++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md @@ -67,3 +67,21 @@ Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison 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. + +## Phase-3 revalidation + +Previous D: runtime source cycle closed at a3182185f0 after corrected whitespace receipt. Final executable/native proof remains open; Windows commands unsupported. Continue integration from that exact parent. Carry current React resource/Select/Notice/icon conventions with no dependency additions. All locales inherit original translations with the unavailable-state opt-in message added consistently. + +Server adaptation: preserve current quota-reset and Grok coupon lazy dispatch. Add remote namespace handler before normal configuration routes. It answers disabled GET status with available:false and empty collections before loading workspace runtime; mutations when disabled refuse. Pair/agent paths require explicit guard before lazy imports and existing Origin/device-token validation. WebSocket data stores only structural receive/open/close callbacks; no concrete Hub class imports in ws-bridge. Upgrade closure owns hub/device association and close cleanup. Management dependency seams use structural Pick projections of only public Hub/session operations; all are import type and erased at runtime. Runtime modules use narrow config imports from phase 2, eliminating the prior broad runtime cycle. + +Shutdown: a promise-local initialized workspace module reference is set only on actual workspace route activation; shutdown calls initialized service getters only when that reference exists. It never dynamically imports remote runtime merely because runtimeRole is hub. Management-only activation also needs lifecycle-owned shutdown registration or a retained optional shutdown callback; resolve before B and test both paths. + +NEW tests/clients/remote-workspace-activation.test.ts covers hub+flag guard, disabled management status without store writes and unauthorized principal refusal before dependency construction. Existing server tests get explicit isolated flag setup/restore; no real devices. CLI capabilities list pair/agent/status, no Hub-status automation introduced. Regenerate skills/ocx reference surface through its existing generator (documentation only). Docs state OCX_REMOTE_WORKSPACE_ENABLED=1 opt-in, default read-only sessions, Linux conditional exec and both desktop native helpers refusing commands. + +Rendering: this worktree has no node_modules or gui/node_modules. Do not install or run a local build. Prefer final hosted package artifacts for a local static render with synthetic API responses; if no artifact exists, retain rendering as unmet acceptance and attach no historical screenshot as current evidence. + +### Awaited per-server cleanup decision + +The existing optional-shutdown registry is synchronous best-effort and cannot prove awaited Remote Workspace shutdown. Reuse server.stop's existing runListenerShutdown array instead. Add a per-server retained shutdown callback and a ManagementApiDeps onRemoteWorkspaceShutdown callback setter. Workspace management resolves its already-loaded services then registers an initialized-only cleanup closure through that setter; pair/agent loader registers the same kind of closure. server.stop calls the retained callback if present. No callback means no remote import/work. Keep registration idempotent and closure references scoped to the current config/server; tests cover management-only initialization and explicit stop. Do not change the global optional-shutdown API. + +In-flight initialization refinement: management checks per-server stopping before and after module import, creates Hub/session services synchronously in one turn, then registers initialized-only teardown. Pair/upgrade paths check stopping after lazy load. SessionService rejects create/resume after shutdown even when an availability promise completes later; a regression holds availability across shutdown. This prevents request initialization from creating resources after stop. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b25586f7a6..b76a01cdd5 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -86,6 +86,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..bedd3e73a4 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -13,7 +13,9 @@ the hub's own processes dial `127.0.0.1:` with no credential, thr companion listener. Start from [the recipe below](#linux-systemd-or-macos-launchd), then hand a second machine a ready-made command with [`ocx hub invite`](#inviting-another-machine). -The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its +The management ingress never serves `/v1/*`, `/healthz`, or `/readyz`. When explicitly enabled, +Remote Workspace admits only its paired bearer-authenticated agent WebSocket and one-time pairing +exchange; see [Remote Workspace](/guides/remote-workspace/). Do not publish its port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a public-internet surface and is outside this deployment model. diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md new file mode 100644 index 0000000000..2d5ffb814d --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -0,0 +1,189 @@ +--- +title: Remote Workspace +description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment. +--- + +Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the +project files, commands, tests, and build compute. A phone or third computer can control the session +through the Hub dashboard. + +```text +Phone browser -> Computer 1 OCX Hub -> encrypted channel -> Computer 2 OCX Executor + Codex / Claude / Pi project and commands + logins and sessions no coding CLI login +``` + +The Executor needs OpenCodex only. It does not need Codex, Claude Code, Pi, a ChatGPT login, or a +provider API key. It opens an outbound WebSocket to the Hub, so the Executor needs no public port or +router port-forward. + +:::caution[Experimental foundation] +Remote Workspace is opt-in and not a production rollout. Linux offers file tools and conditional +bubblewrap command execution. Windows and macOS offer file tools only: their official native +helpers reject probe and command requests. Windows commands remain unsupported until a verified +lifecycle owner can retain cleanup authority through cancellation. Missing command support never +falls back to executing on the Hub. +::: + +## Set up the Hub + +Computer 1 owns every coding-agent login and model session. Install and log in to whichever agents +you want to use there, then run OpenCodex as a Hub: + +```bash +ocx config set runtimeRole hub +OCX_REMOTE_WORKSPACE_ENABLED=1 ocx start +ocx gui +``` + +Set `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process itself; setting it only for a dashboard +command does not enable an already-running service. A Hub with no explicit opt-in returns disabled +status without creating workspace keys or probing coding-agent runtimes. + +Use an authenticated HTTPS deployment when opening the dashboard from a phone or another computer. +See [Remote Hub Deployment](/guides/remote-hub/) for the supported management-ingress and Tailscale +pattern. Do not publish an unauthenticated local dashboard port. + +Codex Remote Workspace uses current App Server permission profiles. If the Hub's selected Codex +configuration still sets legacy `sandbox_mode` or `sandbox_workspace_write`, the dashboard reports +Codex as unavailable instead of starting with a weaker boundary. Migrate that Codex profile before +using the feature; do not configure both the legacy sandbox and a permission profile. + +## Pair an Executor + +1. Open **Remote Workspace** in the Hub dashboard. +2. Select **Create pairing code**. +3. On Computer 2, change into the project directory you want to expose. +4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. + It pairs the current directory and keeps + `ocx remote-workspace agent` connected in that terminal. + +The equivalent manual flow is: + +```bash +cd /path/to/project +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" +ocx remote-workspace agent +``` + +On Windows PowerShell, use the command shown in the dashboard. The equivalent manual form is: + +```powershell +$pairingCode = 'ONE-TIME-CODE' +$pairingCode | ocx remote-workspace pair 'https://your-hub.example' ` + --pairing-code-stdin --root (Get-Location).Path +if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent } +``` + +The current OCX Bun executable is added as one read-only file to the Linux sandbox automatically. If +the project needs a user-installed toolchain outside the system paths, pair it explicitly without +exposing the rest of the home directory: + +```bash +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" \ + --toolchain-root "$HOME/.nvm/versions/node/v24/bin" +``` + +The native helper source is packaged for review. Building it does not enable Windows or macOS +commands in this carry. `--executor-helper` remains a reviewed-helper selector; binary existence +or a configured path does not prove command support. + +The one-time code is read from standard input, not command-line arguments. Pairing creates a local +device signing key and a device-scoped bearer. The Hub stores only its hash and never receives the +real Executor path. Stop the foreground agent with Ctrl+C; running it again reconnects the same +device. + +Check local enrollment without printing secrets: + +```bash +ocx remote-workspace status +``` + +## Start a remote coding session + +In the dashboard choose: + +1. the online computer; +2. one locally approved workspace folder; +3. Codex, Claude Code, or Pi from the Hub; and +4. an access mode. + +**Read only** is the default and exposes directory listing and file reading. The write option is +shown as **Edit files and run commands** only when that Executor passed a command-sandbox probe; +otherwise it is shown as **Edit files only**. The dashboard shows two separate locations so it is +clear that the model and login remain on the Hub while workspace operations run on the selected +computer. + +Send prompts from the Hub dashboard on Computer 1, Computer 3, or a phone. The session cannot switch +to another computer or folder silently. If the Executor disconnects, the session enters +**Executor offline** and never falls back to the Hub's filesystem. + +**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn, +cancels an active Executor command, and prevents a late response from reopening the stopped +session. + +## Restart and reconnect behavior + +The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, +an unfinished session waits for its original Executor. Once that device reconnects, the next prompt +resumes the original Codex thread, Claude Code session, or Pi session ID. + +Claude Code creates its durable history on the first completed prompt. If the Hub stops before a +new Claude session has completed any prompt, there is no conversation to resume; start a new +session instead. + +A changed capability manifest does not silently weaken an existing session. Start a new session if +the Executor loses command containment or its available tools change. Revoking a computer closes its +socket and stops sessions bound to it. + +## Security boundaries + +- Provider credentials and coding-agent history remain on the Hub. +- Executor private keys, device bearer, and real root paths remain in its owner-only OCX state. +- Pairing-code failures are limited per kernel-observed peer on every listener. Ten failed codes in + ten minutes return a generic `429` with `Retry-After`; the Hub retains only bounded, expiring + hashes of those source identities. Tailscale Serve users share the management listener's loopback + bucket because a direct local caller could forge its identity header. +- Each work session uses an Ed25519-signed ephemeral P-256 ECDH handshake and ordered + AES-256-GCM messages. +- A socket is not shown as online until both sides agree on its current capability manifest. +- Reconnection may remove a capability when its local sandbox is unavailable, but never adds a + capability outside the grant recorded at pairing. +- Every request is bound to one model thread, device, root, access mode, and capability set. +- Paths are relative, canonicalized, bounded, and rejected on symlink, junction, or parent-directory + escape. Windows device names, alternate data streams, and trailing-dot/space aliases are denied. +- Executor operations are serialized, opened file identities are rechecked, and write hashes are + checked again immediately before atomic replacement. Replacing an approved root requires pairing + it again, and toolchain roots are revalidated before each command. +- File reads/writes reject hard-linked files. Before command execution, OCX scans at most 250,000 + workspace entries and disables the command path if any non-directory entry has multiple links; + path sandboxes cannot prove whether the other name for that inode is outside the approved root. +- Linux commands run through bubblewrap with one writable workspace, cleared environment, private + process namespaces, the current OCX Bun executable as one read-only file, bounded output + and timeout, and network disabled by default. Dedicated confinement tests require an explicitly + configured hosted environment; a green generic suite does not prove they ran. +- macOS advertises file tools only. A process group cannot contain a descendant after it calls + `setsid()`, and importing a broad Apple Seatbelt system profile merely to start a command would + expose unrelated host-service authority. The native helper therefore rejects both its probe and + direct command requests until OCX has a narrow, revocable descendant-containment owner. +- Windows and macOS native command requests fail closed. Their direct-helper refusal tests must be + distinguished from functioning command-confinement evidence; Windows command acceptance is open. +- The pinned native helper must be outside every approved writable workspace. OCX checks this both + before advertising command support and immediately before each command, so workspace code cannot + replace the binary that enforces its next sandbox. +- Stopping a session cancels an active Executor command and cleans up the Hub model process and + loopback tool bridge. Windows stops the owned npm-wrapper process tree rather than leaving its + Node child behind; Linux and macOS force-stop a CLI only if it ignores the graceful stop window. + +The Hub intentionally sees prompts and model output because it runs the coding agent. End-to-end +encryption protects Executor RPC payloads. The paired Hub is trusted to select approved roots over +authenticated WSS; it is not blind to its own model conversation. + +## Current scope + +Remote Workspace does not copy or synchronize credentials to other computers. It is separate from +Remote Hub provider routing and from any future hosted compute or Super Sync product. A production +release still requires signed Windows helper packaging, native CI proof on the exact binaries, +independent maintainer review, and a real three-computer acceptance run. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e39d0cc018..d91e6c1865 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -18,6 +18,19 @@ opencodex state. `ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`. +### `ocx remote-workspace` + +`ocx remote-workspace pair --pairing-code-stdin --root ` enrolls the local +computer as an OCX-only Executor. Repeat `--root` to approve more folders and use `--name` to +override the hostname. Repeat `--toolchain-root ` to expose a user-installed +Node, Rust, Go, or other toolchain directory read-only inside the command sandbox. On macOS and +Windows private-dogfood builds, `bun run build:remote-workspace-helper` creates the Rust helper that +the pair command discovers automatically; `--executor-helper ` selects another +explicitly reviewed build and pins its digest in local Executor state. +`ocx remote-workspace agent` maintains the outbound encrypted connection; +`ocx remote-workspace status [--json]` reports the Hub, device, roots, and advertised capabilities +without printing its bearer or private key. See [Remote Workspace](/guides/remote-workspace/). + - [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics, catalog sync, the dashboard, and updates. - [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cd8b2450c2..75a0f89fb2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -145,6 +145,34 @@ should use the dedicated paths above so an older proxy cannot ignore a profile s See [Aside profile controls](/guides/integrations/#aside-profile-controls) for CLI commands and the proxy upgrade, restart, and retry sequence. +### Remote Workspace + +Requires Hub mode and `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process. Disabled status is +readable; mutations refuse without initializing workspace services. + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent | +| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity | +| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — | +| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target | +| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure | +| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session | +| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device | + +Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens +`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints +are not general management API authority. The bearer is device-scoped, and each work session adds a +signed E2EE handshake. Ten failed pairing codes from one kernel-observed peer return `429` with +`Retry-After` for the remainder of the fixed ten-minute window. Tailscale Serve clients share the +management listener's loopback peer bucket; the identity header is not used for throttling because +a direct local process could forge it. See [Remote Workspace](/guides/remote-workspace/) for the +end-user flow and trust boundaries. + +Session snapshots include `resumable`. It becomes true only after the selected coding-agent runtime +has durable history; notably, a new Claude Code session remains false until its first prompt +completes. + ### Combos | Method and path | Purpose | Notable errors | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..95b175711f 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,6 +10,7 @@ import Storage from "./pages/Storage"; import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; +import RemoteWorkspace from "./pages/RemoteWorkspace"; import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; @@ -35,6 +36,7 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", + remote: "nav.remote", "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -68,6 +70,7 @@ const NAV: NavEntry[] = [ { id: "logs", tkey: "nav.logs", Icon: IconList }, { id: "usage", tkey: "nav.usage", Icon: IconActivity }, { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, + { id: "remote", tkey: "nav.remote", Icon: IconMonitor }, { id: "integrations", tkey: "nav.integrations", Icon: IconGlobe }, ]; @@ -432,6 +435,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } + {page === "remote" && } {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index cf9762ab71..5d8588358b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -11,6 +11,7 @@ export type Page = | "logs" | "usage" | "storage" + | "remote" | "codex-set" | "integrations"; @@ -23,6 +24,7 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", + "remote", "codex-set", "integrations", ]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 868e2a2855..5fc7be9304 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2705,4 +2705,64 @@ export const de: Record = { "models.pickerOrder.saveDraft": "Entwurf speichern", "models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen", "models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.", + "nav.remote": "Remote-Arbeitsbereich", + "remote.title": "Remote-Arbeitsbereich", + "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.", + "remote.loading": "Remote-Arbeitsbereich wird geladen…", + "remote.loadFailed": "Remote-Arbeitsbereich konnte nicht geladen werden.", + "remote.hubRequired": "Starten Sie den Hub im Hub-Modus mit OCX_REMOTE_WORKSPACE_ENABLED=1, um Remote Workspace zu aktivieren.", + "remote.refresh": "Aktualisieren", + "remote.addComputer": "Computer hinzufügen", + "remote.addComputerHint": "Gib lokal Ordner frei und halte den reinen OCX-Executor mit diesem Hub verbunden.", + "remote.createPairing": "Kopplungscode erstellen", + "remote.pairingCode": "Einmaliger Kopplungscode", + "remote.pairingExpires": "Läuft um {time} ab", + "remote.pairingCommand": "Auf dem hinzuzufügenden Computer ausführen", + "remote.pairingCommandPosix": "Linux- / macOS-Terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Befehl kopieren", + "remote.copied": "Kopiert", + "remote.devices": "Computer", + "remote.noDevices": "Noch keine Computer gekoppelt.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Computer widerrufen", + "remote.revokeConfirm": "{name} widerrufen? Aktive Sitzungen auf diesem Computer werden beendet.", + "remote.newSession": "Neue Remote-Sitzung", + "remote.device": "Computer", + "remote.folder": "Arbeitsordner", + "remote.runtime": "Coding-Agent", + "remote.access": "Workspace-Zugriff", + "remote.access.readOnly": "Nur lesen", + "remote.access.workspace": "Dateien bearbeiten und Befehle ausführen", + "remote.access.workspaceFilesOnly": "Nur Dateien bearbeiten", + "remote.unavailable": "Nicht verfügbar", + "remote.capability.full": "Dateien + isolierte Befehle", + "remote.capability.files": "Nur Dateiwerkzeuge", + "remote.runsOnHub": "Modell und Anmeldung bleiben auf diesem Hub", + "remote.runsReadOnly": "Dateien können auf diesem Computer nur gelesen werden", + "remote.runsFilesCommands": "Dateien, Builds und Befehle laufen hier", + "remote.runsFilesOnly": "Dateiwerkzeuge laufen hier; Befehls-Sandbox nicht verfügbar", + "remote.execUnavailable": "Dieser Computer kann Dateien bearbeiten, aber Builds und Terminalbefehle sind ohne unterstützte Betriebssystem-Sandbox deaktiviert.", + "remote.notResumable": "Diese Sitzung wurde beendet, bevor der Coding-Agent einen dauerhaften Verlauf erstellt hat. Starten Sie eine neue Remote-Sitzung.", + "remote.startSession": "Remote-Sitzung starten", + "remote.sessionStarted": "Remote-Sitzung ist bereit.", + "remote.sessions": "Sitzungen", + "remote.noSessions": "Wähle einen Online-Computer, Ordner und Coding-Agenten.", + "remote.events": "Aktivität der Remote-Sitzung", + "remote.noEvents": "Noch keine Aktivität.", + "remote.prompt": "Nachricht", + "remote.promptPlaceholder": "Bitte den Hub-Agenten, im ausgewählten Remote-Ordner zu arbeiten…", + "remote.send": "Senden", + "remote.stop": "Sitzung stoppen", + "remote.requestFailed": "Remote-Workspace-Anfrage fehlgeschlagen.", + "remote.status.starting": "Startet", + "remote.status.ready": "Bereit", + "remote.status.running": "Läuft", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Fehlgeschlagen", + "remote.status.stopped": "Gestoppt", + "remote.event.status": "Status", + "remote.event.tool": "Remote-Werkzeug", + "remote.event.error": "Fehler", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dfa9ad90e9..558e3cd7b2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2739,6 +2739,66 @@ export const en = { "models.pickerOrder.saveDraft": "Save draft", "models.pickerOrder.reloadDraft": "Reload and discard draft", "models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.", + "nav.remote": "Remote Workspace", + "remote.title": "Remote Workspace", + "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.", + "remote.loading": "Loading Remote Workspace…", + "remote.loadFailed": "Could not load Remote Workspace.", + "remote.hubRequired": "Use Hub mode and start the Hub with OCX_REMOTE_WORKSPACE_ENABLED=1 to enable Remote Workspace.", + "remote.refresh": "Refresh", + "remote.addComputer": "Add a computer", + "remote.addComputerHint": "Approve one or more folders locally, then keep the OCX-only executor connected to this Hub.", + "remote.createPairing": "Create pairing code", + "remote.pairingCode": "One-time pairing code", + "remote.pairingExpires": "Expires at {time}", + "remote.pairingCommand": "Run on the computer you are adding", + "remote.pairingCommandPosix": "Linux / macOS terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copy command", + "remote.copied": "Copied", + "remote.devices": "Computers", + "remote.noDevices": "No computers are paired yet.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Revoke computer", + "remote.revokeConfirm": "Revoke {name}? Active sessions on this computer will stop.", + "remote.newSession": "New remote session", + "remote.device": "Computer", + "remote.folder": "Workspace folder", + "remote.runtime": "Coding agent", + "remote.access": "Workspace access", + "remote.access.readOnly": "Read only", + "remote.access.workspace": "Edit files and run commands", + "remote.access.workspaceFilesOnly": "Edit files only", + "remote.unavailable": "Unavailable", + "remote.capability.full": "Files + sandboxed commands", + "remote.capability.files": "File tools only", + "remote.runsOnHub": "Model and login stay on this Hub", + "remote.runsReadOnly": "Files can only be read on this computer", + "remote.runsFilesCommands": "Files, builds, and commands run here", + "remote.runsFilesOnly": "File tools run here; command sandbox unavailable", + "remote.execUnavailable": "This computer can edit files, but builds and terminal commands are disabled because a supported OS sandbox is not available.", + "remote.notResumable": "This session stopped before the coding agent created durable history. Start a new remote session.", + "remote.startSession": "Start remote session", + "remote.sessionStarted": "Remote session is ready.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choose an online computer, folder, and coding agent to start.", + "remote.events": "Remote session activity", + "remote.noEvents": "No activity yet.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Ask the Hub agent to work inside the selected remote folder…", + "remote.send": "Send", + "remote.stop": "Stop session", + "remote.requestFailed": "Remote Workspace request failed.", + "remote.status.starting": "Starting", + "remote.status.ready": "Ready", + "remote.status.running": "Running", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Failed", + "remote.status.stopped": "Stopped", + "remote.event.status": "Status", + "remote.event.tool": "Remote tool", + "remote.event.error": "Error", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2cf33a7a96..849dfb4e50 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2693,4 +2693,64 @@ export const fr: Record = { "models.pickerOrder.saveDraft": "Enregistrer le brouillon", "models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon", "models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.", + "nav.remote": "Espace distant", + "remote.title": "Espace de travail distant", + "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.", + "remote.loading": "Chargement de l'espace distant…", + "remote.loadFailed": "Impossible de charger l'espace distant.", + "remote.hubRequired": "Démarrez le Hub en mode Hub avec OCX_REMOTE_WORKSPACE_ENABLED=1 pour activer Remote Workspace.", + "remote.refresh": "Actualiser", + "remote.addComputer": "Ajouter un ordinateur", + "remote.addComputerHint": "Autorisez localement un ou plusieurs dossiers, puis gardez l'exécuteur OCX connecté à ce Hub.", + "remote.createPairing": "Créer un code d'association", + "remote.pairingCode": "Code d'association à usage unique", + "remote.pairingExpires": "Expire à {time}", + "remote.pairingCommand": "À exécuter sur l'ordinateur à ajouter", + "remote.pairingCommandPosix": "Terminal Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copier la commande", + "remote.copied": "Copié", + "remote.devices": "Ordinateurs", + "remote.noDevices": "Aucun ordinateur associé.", + "remote.online": "En ligne", + "remote.offline": "Hors ligne", + "remote.revoke": "Révoquer l'ordinateur", + "remote.revokeConfirm": "Révoquer {name} ? Ses sessions actives seront arrêtées.", + "remote.newSession": "Nouvelle session distante", + "remote.device": "Ordinateur", + "remote.folder": "Dossier de travail", + "remote.runtime": "Agent de code", + "remote.access": "Accès à l’espace de travail", + "remote.access.readOnly": "Lecture seule", + "remote.access.workspace": "Modifier les fichiers et exécuter des commandes", + "remote.access.workspaceFilesOnly": "Modifier uniquement les fichiers", + "remote.unavailable": "Indisponible", + "remote.capability.full": "Fichiers + commandes isolées", + "remote.capability.files": "Outils de fichiers uniquement", + "remote.runsOnHub": "Le modèle et la connexion restent sur ce Hub", + "remote.runsReadOnly": "Les fichiers de cet ordinateur sont accessibles en lecture seule", + "remote.runsFilesCommands": "Les fichiers, builds et commandes s’exécutent ici", + "remote.runsFilesOnly": "Les outils de fichiers s’exécutent ici ; bac à sable indisponible", + "remote.execUnavailable": "Cet ordinateur peut modifier les fichiers, mais les builds et commandes de terminal sont désactivés faute de bac à sable système pris en charge.", + "remote.notResumable": "Cette session s’est arrêtée avant que l’agent de code ne crée un historique durable. Démarrez une nouvelle session distante.", + "remote.startSession": "Démarrer la session distante", + "remote.sessionStarted": "La session distante est prête.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choisissez un ordinateur en ligne, un dossier et un agent de code.", + "remote.events": "Activité de la session distante", + "remote.noEvents": "Aucune activité pour le moment.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Demandez à l'agent du Hub de travailler dans le dossier distant choisi…", + "remote.send": "Envoyer", + "remote.stop": "Arrêter la session", + "remote.requestFailed": "La requête d'espace distant a échoué.", + "remote.status.starting": "Démarrage", + "remote.status.ready": "Prêt", + "remote.status.running": "En cours", + "remote.status.waiting": "Exécuteur hors ligne", + "remote.status.failed": "Échec", + "remote.status.stopped": "Arrêté", + "remote.event.status": "État", + "remote.event.tool": "Outil distant", + "remote.event.error": "Erreur", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0f51bbb2d9..6d3c3a4666 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2726,4 +2726,64 @@ export const ja: Record = { "models.pickerOrder.saveDraft": "下書きを保存", "models.pickerOrder.reloadDraft": "下書きを破棄して再読み込み", "models.pickerOrder.catalogRequired": "モデルの識別情報が不足しているか曖昧です。モデルページを再読み込みしてカタログを更新してからカスタム順序を編集してください。", + "nav.remote": "リモートワークスペース", + "remote.title": "リモートワークスペース", + "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。", + "remote.loading": "リモートワークスペースを読み込み中…", + "remote.loadFailed": "リモートワークスペースを読み込めませんでした。", + "remote.hubRequired": "Hub モードで OCX_REMOTE_WORKSPACE_ENABLED=1 を設定して Hub を起動すると、Remote Workspace を有効にできます。", + "remote.refresh": "更新", + "remote.addComputer": "コンピューターを追加", + "remote.addComputerHint": "ローカルでフォルダーを承認し、OCX 専用エグゼキューターをこの Hub に接続したままにします。", + "remote.createPairing": "ペアリングコードを作成", + "remote.pairingCode": "ワンタイムペアリングコード", + "remote.pairingExpires": "{time} に期限切れ", + "remote.pairingCommand": "追加するコンピューターで実行", + "remote.pairingCommandPosix": "Linux / macOS ターミナル", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "コマンドをコピー", + "remote.copied": "コピー済み", + "remote.devices": "コンピューター", + "remote.noDevices": "ペアリング済みのコンピューターはありません。", + "remote.online": "オンライン", + "remote.offline": "オフライン", + "remote.revoke": "コンピューターを解除", + "remote.revokeConfirm": "{name} を解除しますか?このコンピューターの実行中セッションは停止します。", + "remote.newSession": "新しいリモートセッション", + "remote.device": "コンピューター", + "remote.folder": "ワークスペースフォルダー", + "remote.runtime": "コーディングエージェント", + "remote.access": "ワークスペース権限", + "remote.access.readOnly": "読み取り専用", + "remote.access.workspace": "ファイル編集とコマンド実行", + "remote.access.workspaceFilesOnly": "ファイル編集のみ", + "remote.unavailable": "利用不可", + "remote.capability.full": "ファイル + 分離されたコマンド", + "remote.capability.files": "ファイルツールのみ", + "remote.runsOnHub": "モデルとログインはこの Hub に保持", + "remote.runsReadOnly": "このコンピューターのファイルは読み取りのみ", + "remote.runsFilesCommands": "ファイル、ビルド、コマンドはここで実行", + "remote.runsFilesOnly": "ファイルツールのみここで実行、コマンド分離は未対応", + "remote.execUnavailable": "このコンピューターではファイル編集はできますが、対応する OS サンドボックスがないためビルドとターミナルコマンドは無効です。", + "remote.notResumable": "コーディングエージェントが永続的な履歴を作成する前にセッションが停止しました。新しいリモートセッションを開始してください。", + "remote.startSession": "リモートセッションを開始", + "remote.sessionStarted": "リモートセッションの準備ができました。", + "remote.sessions": "セッション", + "remote.noSessions": "オンラインのコンピューター、フォルダー、エージェントを選択してください。", + "remote.events": "リモートセッションのアクティビティ", + "remote.noEvents": "まだアクティビティはありません。", + "remote.prompt": "メッセージ", + "remote.promptPlaceholder": "選択したリモートフォルダーでの作業を Hub エージェントに依頼…", + "remote.send": "送信", + "remote.stop": "セッションを停止", + "remote.requestFailed": "リモートワークスペースの要求に失敗しました。", + "remote.status.starting": "開始中", + "remote.status.ready": "準備完了", + "remote.status.running": "実行中", + "remote.status.waiting": "エグゼキューターがオフライン", + "remote.status.failed": "失敗", + "remote.status.stopped": "停止済み", + "remote.event.status": "状態", + "remote.event.tool": "リモートツール", + "remote.event.error": "エラー", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1db3ad6a32..347321bdc0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2727,4 +2727,64 @@ export const ko: Record = { "models.pickerOrder.saveDraft": "초안 저장", "models.pickerOrder.reloadDraft": "초안 버리고 다시 불러오기", "models.pickerOrder.catalogRequired": "모델 식별 정보가 없거나 모호합니다. 모델 페이지를 새로고침해 목록을 갱신한 뒤 사용자 지정 순서를 편집하세요.", + "nav.remote": "원격 워크스페이스", + "remote.title": "원격 워크스페이스", + "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.", + "remote.loading": "원격 워크스페이스 불러오는 중…", + "remote.loadFailed": "원격 워크스페이스를 불러오지 못했습니다.", + "remote.hubRequired": "허브 모드에서 OCX_REMOTE_WORKSPACE_ENABLED=1로 허브를 시작하면 원격 작업 공간을 사용할 수 있습니다.", + "remote.refresh": "새로고침", + "remote.addComputer": "컴퓨터 추가", + "remote.addComputerHint": "추가할 컴퓨터에서 폴더를 승인하고 OCX 전용 실행기를 이 Hub에 계속 연결하세요.", + "remote.createPairing": "페어링 코드 만들기", + "remote.pairingCode": "일회용 페어링 코드", + "remote.pairingExpires": "{time}에 만료", + "remote.pairingCommand": "추가할 컴퓨터에서 실행", + "remote.pairingCommandPosix": "Linux / macOS 터미널", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "명령어 복사", + "remote.copied": "복사됨", + "remote.devices": "컴퓨터", + "remote.noDevices": "아직 페어링된 컴퓨터가 없습니다.", + "remote.online": "온라인", + "remote.offline": "오프라인", + "remote.revoke": "컴퓨터 연결 해제", + "remote.revokeConfirm": "{name} 연결을 해제할까요? 이 컴퓨터의 활성 세션이 중지됩니다.", + "remote.newSession": "새 원격 세션", + "remote.device": "컴퓨터", + "remote.folder": "워크스페이스 폴더", + "remote.runtime": "코딩 에이전트", + "remote.access": "워크스페이스 권한", + "remote.access.readOnly": "읽기 전용", + "remote.access.workspace": "파일 편집 및 명령 실행", + "remote.access.workspaceFilesOnly": "파일 편집만", + "remote.unavailable": "사용 불가", + "remote.capability.full": "파일 + 격리된 명령 실행", + "remote.capability.files": "파일 도구만 지원", + "remote.runsOnHub": "모델과 로그인은 이 Hub에서 유지", + "remote.runsReadOnly": "이 컴퓨터의 파일은 읽기만 가능", + "remote.runsFilesCommands": "파일, 빌드, 명령은 이 컴퓨터에서 실행", + "remote.runsFilesOnly": "파일 도구만 이 컴퓨터에서 실행, 명령 격리 미지원", + "remote.execUnavailable": "이 컴퓨터의 파일은 편집할 수 있지만, 지원되는 OS 격리 기능이 없어 빌드와 터미널 명령은 비활성화됩니다.", + "remote.notResumable": "코딩 에이전트가 세션 기록을 만들기 전에 중단되었습니다. 새 원격 세션을 시작하세요.", + "remote.startSession": "원격 세션 시작", + "remote.sessionStarted": "원격 세션이 준비되었습니다.", + "remote.sessions": "세션", + "remote.noSessions": "온라인 컴퓨터, 폴더, 코딩 에이전트를 선택해 시작하세요.", + "remote.events": "원격 세션 활동", + "remote.noEvents": "아직 활동이 없습니다.", + "remote.prompt": "메시지", + "remote.promptPlaceholder": "Hub 에이전트에게 선택한 원격 폴더에서 작업을 요청하세요…", + "remote.send": "보내기", + "remote.stop": "세션 중지", + "remote.requestFailed": "원격 워크스페이스 요청에 실패했습니다.", + "remote.status.starting": "시작 중", + "remote.status.ready": "준비됨", + "remote.status.running": "실행 중", + "remote.status.waiting": "실행기 오프라인", + "remote.status.failed": "실패", + "remote.status.stopped": "중지됨", + "remote.event.status": "상태", + "remote.event.tool": "원격 도구", + "remote.event.error": "오류", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fc6f61c152..a6e7f5cedd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2728,4 +2728,64 @@ export const ru: Record = { "models.pickerOrder.saveDraft": "Сохранить черновик", "models.pickerOrder.reloadDraft": "Перезагрузить и сбросить черновик", "models.pickerOrder.catalogRequired": "Идентификаторы моделей отсутствуют или неоднозначны. Перезагрузите страницу моделей, чтобы обновить каталог перед редактированием порядка.", + "nav.remote": "Удалённое рабочее пространство", + "remote.title": "Удалённое рабочее пространство", + "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.", + "remote.loading": "Загрузка удалённого рабочего пространства…", + "remote.loadFailed": "Не удалось загрузить удалённое рабочее пространство.", + "remote.hubRequired": "Для включения Remote Workspace запустите Hub в режиме Hub с OCX_REMOTE_WORKSPACE_ENABLED=1.", + "remote.refresh": "Обновить", + "remote.addComputer": "Добавить компьютер", + "remote.addComputerHint": "Разрешите локальные папки и держите исполнитель только с OCX подключённым к этому Hub.", + "remote.createPairing": "Создать код сопряжения", + "remote.pairingCode": "Одноразовый код сопряжения", + "remote.pairingExpires": "Истекает в {time}", + "remote.pairingCommand": "Запустите на добавляемом компьютере", + "remote.pairingCommandPosix": "Терминал Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Копировать команду", + "remote.copied": "Скопировано", + "remote.devices": "Компьютеры", + "remote.noDevices": "Сопряжённых компьютеров пока нет.", + "remote.online": "В сети", + "remote.offline": "Не в сети", + "remote.revoke": "Отозвать компьютер", + "remote.revokeConfirm": "Отозвать {name}? Активные сеансы на этом компьютере будут остановлены.", + "remote.newSession": "Новый удалённый сеанс", + "remote.device": "Компьютер", + "remote.folder": "Папка рабочего пространства", + "remote.runtime": "Агент программирования", + "remote.access": "Доступ к рабочей области", + "remote.access.readOnly": "Только чтение", + "remote.access.workspace": "Изменять файлы и выполнять команды", + "remote.access.workspaceFilesOnly": "Только изменять файлы", + "remote.unavailable": "Недоступно", + "remote.capability.full": "Файлы + изолированные команды", + "remote.capability.files": "Только файловые инструменты", + "remote.runsOnHub": "Модель и вход остаются на этом Hub", + "remote.runsReadOnly": "Файлы на этом компьютере доступны только для чтения", + "remote.runsFilesCommands": "Файлы, сборки и команды выполняются здесь", + "remote.runsFilesOnly": "Здесь работают только файловые инструменты; песочница команд недоступна", + "remote.execUnavailable": "На этом компьютере можно редактировать файлы, но сборки и команды терминала отключены без поддерживаемой системной песочницы.", + "remote.notResumable": "Сеанс остановился до создания постоянной истории агентом. Запустите новый удалённый сеанс.", + "remote.startSession": "Запустить удалённый сеанс", + "remote.sessionStarted": "Удалённый сеанс готов.", + "remote.sessions": "Сеансы", + "remote.noSessions": "Выберите компьютер в сети, папку и агента программирования.", + "remote.events": "Активность удалённого сеанса", + "remote.noEvents": "Активности пока нет.", + "remote.prompt": "Сообщение", + "remote.promptPlaceholder": "Попросите агент Hub работать в выбранной удалённой папке…", + "remote.send": "Отправить", + "remote.stop": "Остановить сеанс", + "remote.requestFailed": "Запрос удалённого рабочего пространства завершился ошибкой.", + "remote.status.starting": "Запуск", + "remote.status.ready": "Готово", + "remote.status.running": "Выполняется", + "remote.status.waiting": "Исполнитель не в сети", + "remote.status.failed": "Ошибка", + "remote.status.stopped": "Остановлено", + "remote.event.status": "Состояние", + "remote.event.tool": "Удалённый инструмент", + "remote.event.error": "Ошибка", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ee6ae93adf..1aaeee8027 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2728,4 +2728,64 @@ export const tr: Record = { "models.pickerOrder.saveDraft": "Taslağı kaydet", "models.pickerOrder.reloadDraft": "Yeniden yükle ve taslağı sil", "models.pickerOrder.catalogRequired": "Model kimlikleri eksik veya belirsiz. Özel sırayı düzenlemeden önce kataloğu yenilemek için Modeller sayfasını yeniden yükleyin.", + "nav.remote": "Uzak Çalışma Alanı", + "remote.title": "Uzak Çalışma Alanı", + "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.", + "remote.loading": "Uzak çalışma alanı yükleniyor…", + "remote.loadFailed": "Uzak çalışma alanı yüklenemedi.", + "remote.hubRequired": "Remote Workspace özelliğini açmak için Hub modunda OCX_REMOTE_WORKSPACE_ENABLED=1 ile Hub başlatın.", + "remote.refresh": "Yenile", + "remote.addComputer": "Bilgisayar ekle", + "remote.addComputerHint": "Klasörleri yerel olarak onaylayın ve yalnızca OCX kurulu yürütücüyü bu Hub'a bağlı tutun.", + "remote.createPairing": "Eşleştirme kodu oluştur", + "remote.pairingCode": "Tek kullanımlık eşleştirme kodu", + "remote.pairingExpires": "{time} saatinde sona erer", + "remote.pairingCommand": "Eklenecek bilgisayarda çalıştırın", + "remote.pairingCommandPosix": "Linux / macOS terminali", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Komutu kopyala", + "remote.copied": "Kopyalandı", + "remote.devices": "Bilgisayarlar", + "remote.noDevices": "Henüz eşleştirilmiş bilgisayar yok.", + "remote.online": "Çevrimiçi", + "remote.offline": "Çevrimdışı", + "remote.revoke": "Bilgisayarı iptal et", + "remote.revokeConfirm": "{name} iptal edilsin mi? Bu bilgisayardaki etkin oturumlar durur.", + "remote.newSession": "Yeni uzak oturum", + "remote.device": "Bilgisayar", + "remote.folder": "Çalışma alanı klasörü", + "remote.runtime": "Kodlama aracısı", + "remote.access": "Çalışma alanı erişimi", + "remote.access.readOnly": "Salt okunur", + "remote.access.workspace": "Dosyaları düzenle ve komut çalıştır", + "remote.access.workspaceFilesOnly": "Yalnızca dosyaları düzenle", + "remote.unavailable": "Kullanılamıyor", + "remote.capability.full": "Dosyalar + yalıtılmış komutlar", + "remote.capability.files": "Yalnızca dosya araçları", + "remote.runsOnHub": "Model ve oturum bu Hub üzerinde kalır", + "remote.runsReadOnly": "Bu bilgisayardaki dosyalar yalnızca okunabilir", + "remote.runsFilesCommands": "Dosyalar, derlemeler ve komutlar burada çalışır", + "remote.runsFilesOnly": "Burada yalnızca dosya araçları çalışır; komut yalıtımı yok", + "remote.execUnavailable": "Bu bilgisayar dosyaları düzenleyebilir; ancak desteklenen bir işletim sistemi yalıtımı olmadığı için derlemeler ve terminal komutları devre dışıdır.", + "remote.notResumable": "Kodlama aracısı kalıcı geçmiş oluşturmadan önce oturum durdu. Yeni bir uzak oturum başlatın.", + "remote.startSession": "Uzak oturumu başlat", + "remote.sessionStarted": "Uzak oturum hazır.", + "remote.sessions": "Oturumlar", + "remote.noSessions": "Çevrimiçi bir bilgisayar, klasör ve kodlama aracısı seçin.", + "remote.events": "Uzak oturum etkinliği", + "remote.noEvents": "Henüz etkinlik yok.", + "remote.prompt": "Mesaj", + "remote.promptPlaceholder": "Hub aracısından seçili uzak klasörde çalışmasını isteyin…", + "remote.send": "Gönder", + "remote.stop": "Oturumu durdur", + "remote.requestFailed": "Uzak çalışma alanı isteği başarısız oldu.", + "remote.status.starting": "Başlatılıyor", + "remote.status.ready": "Hazır", + "remote.status.running": "Çalışıyor", + "remote.status.waiting": "Yürütücü çevrimdışı", + "remote.status.failed": "Başarısız", + "remote.status.stopped": "Durduruldu", + "remote.event.status": "Durum", + "remote.event.tool": "Uzak araç", + "remote.event.error": "Hata", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8f38f5c0f4..985beab4aa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2691,4 +2691,64 @@ export const zhTW: Record = { "models.pickerOrder.saveDraft": "儲存草稿", "models.pickerOrder.reloadDraft": "捨棄草稿並重新載入", "models.pickerOrder.catalogRequired": "模型識別資訊缺失或不明確。請重新載入模型頁面以更新目錄,再編輯自訂順序。", + "nav.remote": "遠端工作區", + "remote.title": "遠端工作區", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。", + "remote.loading": "正在載入遠端工作區…", + "remote.loadFailed": "無法載入遠端工作區。", + "remote.hubRequired": "請在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 啟動 Hub,以啟用遠端工作區。", + "remote.refresh": "重新整理", + "remote.addComputer": "新增電腦", + "remote.addComputerHint": "在本機核准一個或多個資料夾,並讓僅安裝 OCX 的執行端持續連線此 Hub。", + "remote.createPairing": "建立配對碼", + "remote.pairingCode": "一次性配對碼", + "remote.pairingExpires": "{time} 到期", + "remote.pairingCommand": "在要新增的電腦上執行", + "remote.pairingCommandPosix": "Linux / macOS 終端機", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "複製命令", + "remote.copied": "已複製", + "remote.devices": "電腦", + "remote.noDevices": "尚未配對電腦。", + "remote.online": "上線", + "remote.offline": "離線", + "remote.revoke": "撤銷電腦", + "remote.revokeConfirm": "撤銷 {name}?此電腦上的作用中工作階段將停止。", + "remote.newSession": "新增遠端工作階段", + "remote.device": "電腦", + "remote.folder": "工作區資料夾", + "remote.runtime": "程式設計代理", + "remote.access": "工作區權限", + "remote.access.readOnly": "唯讀", + "remote.access.workspace": "編輯檔案並執行命令", + "remote.access.workspaceFilesOnly": "僅編輯檔案", + "remote.unavailable": "無法使用", + "remote.capability.full": "檔案 + 沙箱命令", + "remote.capability.files": "僅檔案工具", + "remote.runsOnHub": "模型與登入保留在此 Hub", + "remote.runsReadOnly": "此電腦上的檔案僅可讀取", + "remote.runsFilesCommands": "檔案、建置與命令在此電腦執行", + "remote.runsFilesOnly": "僅檔案工具在此執行;命令沙箱無法使用", + "remote.execUnavailable": "此電腦可以編輯檔案,但因沒有支援的作業系統沙箱,建置與終端命令已停用。", + "remote.notResumable": "程式設計代理尚未建立持久歷史記錄時工作階段就已停止。請啟動新的遠端工作階段。", + "remote.startSession": "啟動遠端工作階段", + "remote.sessionStarted": "遠端工作階段已就緒。", + "remote.sessions": "工作階段", + "remote.noSessions": "請選擇上線電腦、資料夾與程式設計代理。", + "remote.events": "遠端工作階段活動", + "remote.noEvents": "尚無活動。", + "remote.prompt": "訊息", + "remote.promptPlaceholder": "請 Hub 代理在所選遠端資料夾中工作…", + "remote.send": "傳送", + "remote.stop": "停止工作階段", + "remote.requestFailed": "遠端工作區要求失敗。", + "remote.status.starting": "正在啟動", + "remote.status.ready": "就緒", + "remote.status.running": "執行中", + "remote.status.waiting": "執行端離線", + "remote.status.failed": "失敗", + "remote.status.stopped": "已停止", + "remote.event.status": "狀態", + "remote.event.tool": "遠端工具", + "remote.event.error": "錯誤", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1cfd82623c..fc198fbb20 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2726,4 +2726,64 @@ export const zh: Record = { "models.pickerOrder.saveDraft": "保存草稿", "models.pickerOrder.reloadDraft": "丢弃草稿并重新加载", "models.pickerOrder.catalogRequired": "模型标识信息缺失或不明确。请重新加载模型页面以刷新目录,再编辑自定义顺序。", + "nav.remote": "远程工作区", + "remote.title": "远程工作区", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。", + "remote.loading": "正在加载远程工作区…", + "remote.loadFailed": "无法加载远程工作区。", + "remote.hubRequired": "请在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 启动 Hub,以启用远程工作区。", + "remote.refresh": "刷新", + "remote.addComputer": "添加电脑", + "remote.addComputerHint": "在本机批准一个或多个文件夹,并让仅安装 OCX 的执行端持续连接此 Hub。", + "remote.createPairing": "创建配对码", + "remote.pairingCode": "一次性配对码", + "remote.pairingExpires": "{time} 过期", + "remote.pairingCommand": "在要添加的电脑上运行", + "remote.pairingCommandPosix": "Linux / macOS 终端", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "复制命令", + "remote.copied": "已复制", + "remote.devices": "电脑", + "remote.noDevices": "尚未配对电脑。", + "remote.online": "在线", + "remote.offline": "离线", + "remote.revoke": "撤销电脑", + "remote.revokeConfirm": "撤销 {name}?该电脑上的活动会话将停止。", + "remote.newSession": "新建远程会话", + "remote.device": "电脑", + "remote.folder": "工作区文件夹", + "remote.runtime": "编程代理", + "remote.access": "工作区权限", + "remote.access.readOnly": "只读", + "remote.access.workspace": "编辑文件并运行命令", + "remote.access.workspaceFilesOnly": "仅编辑文件", + "remote.unavailable": "不可用", + "remote.capability.full": "文件 + 沙箱命令", + "remote.capability.files": "仅文件工具", + "remote.runsOnHub": "模型和登录保留在此 Hub", + "remote.runsReadOnly": "此电脑上的文件仅可读取", + "remote.runsFilesCommands": "文件、构建和命令在此电脑运行", + "remote.runsFilesOnly": "仅文件工具在此运行;命令沙箱不可用", + "remote.execUnavailable": "此电脑可以编辑文件,但由于没有受支持的操作系统沙箱,构建和终端命令已禁用。", + "remote.notResumable": "编码代理尚未创建持久历史记录时会话就已停止。请启动新的远程会话。", + "remote.startSession": "启动远程会话", + "remote.sessionStarted": "远程会话已就绪。", + "remote.sessions": "会话", + "remote.noSessions": "请选择在线电脑、文件夹和编程代理。", + "remote.events": "远程会话活动", + "remote.noEvents": "暂无活动。", + "remote.prompt": "消息", + "remote.promptPlaceholder": "让 Hub 代理在所选远程文件夹中工作…", + "remote.send": "发送", + "remote.stop": "停止会话", + "remote.requestFailed": "远程工作区请求失败。", + "remote.status.starting": "正在启动", + "remote.status.ready": "就绪", + "remote.status.running": "运行中", + "remote.status.waiting": "执行端离线", + "remote.status.failed": "失败", + "remote.status.stopped": "已停止", + "remote.event.status": "状态", + "remote.event.tool": "远程工具", + "remote.event.error": "错误", }; diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx new file mode 100644 index 0000000000..9295e39da2 --- /dev/null +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -0,0 +1,381 @@ +import { useMemo, useRef, useState } from "react"; +import { useKeyedClientResource } from "../client-resource"; +import { readJsonOrThrow } from "../fetch-json"; +import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { remoteWorkspacePairingCommands } from "../remote-workspace-command"; + +type RuntimeProfile = "codex" | "claude" | "pi"; +type RemoteCapability = "workspace.read" | "workspace.write" | "workspace.exec"; +type RemoteAccessMode = "read-only" | "workspace"; +type SessionStatus = "starting" | "ready" | "running" | "waiting_for_executor" | "failed" | "stopped"; + +interface RemoteRoot { id: string; label: string } +interface RemoteDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteCapability[]; + roots: RemoteRoot[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} +interface RuntimeAvailability { available: boolean; version?: string; reason?: string } +interface SessionEvent { sequence: number; at: string; type: "status" | "assistant" | "tool" | "error"; text: string } +interface RemoteSession { + id: string; + profile: RuntimeProfile; + accessMode: RemoteAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteCapability[]; + tools: string[]; + threadId: string | null; + resumable: boolean; + status: SessionStatus; + createdAt: string; + updatedAt: string; + events: SessionEvent[]; +} +interface RemoteWorkspaceState { + available: boolean; + reason?: string; + devices: RemoteDevice[]; + runtimes: Record; + sessions: RemoteSession[]; +} +interface PairingGrant { code: string; expiresAt: string } + +const PROFILES: RuntimeProfile[] = ["codex", "claude", "pi"]; +const PROFILE_LABEL: Record = { codex: "Codex", claude: "Claude Code", pi: "Pi" }; +const STATUS_TKEY: Record = { + starting: "remote.status.starting", + ready: "remote.status.ready", + running: "remote.status.running", + waiting_for_executor: "remote.status.waiting", + failed: "remote.status.failed", + stopped: "remote.status.stopped", +}; +const EVENT_TKEY: Record, TKey> = { + status: "remote.event.status", + tool: "remote.event.tool", + error: "remote.event.error", +}; + +function isRuntimeProfile(value: string): value is RuntimeProfile { + return value === "codex" || value === "claude" || value === "pi"; +} + +function isRemoteAccessMode(value: string): value is RemoteAccessMode { + return value === "read-only" || value === "workspace"; +} + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export default function RemoteWorkspace({ apiBase }: { apiBase: string }) { + const t = useT(); + const resource = useKeyedClientResource( + `remote-workspace:${apiBase}`, + [apiBase], + async signal => { + const response = await fetch(`${apiBase}/api/remote-workspace`, { signal, cache: "no-store" }); + return await readJsonOrThrow(response, t("remote.loadFailed")); + }, + { pollMs: 3_000, deadlineMs: 10_000 }, + ); + const state = resource.data; + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [selectedRootId, setSelectedRootId] = useState(""); + const [selectedProfile, setSelectedProfile] = useState("codex"); + const [selectedAccessMode, setSelectedAccessMode] = useState("read-only"); + const [selectedSessionId, setSelectedSessionId] = useState(""); + const [localSession, setLocalSession] = useState(null); + const [pairing, setPairing] = useState(null); + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState<"pair" | "session" | "revoke" | null>(null); + const [promptPending, setPromptPending] = useState(false); + const [stopPending, setStopPending] = useState(false); + const stoppedSessionId = useRef(null); + const [notice, setNotice] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [copiedCommand, setCopiedCommand] = useState<"posix" | "powershell" | null>(null); + + const devices = state?.devices ?? []; + const effectiveDevice = devices.find(device => device.id === selectedDeviceId) + ?? devices.find(device => device.online) + ?? devices[0] + ?? null; + const effectiveRoot = effectiveDevice?.roots.find(root => root.id === selectedRootId) + ?? effectiveDevice?.roots[0] + ?? null; + const selectedCanExecute = selectedAccessMode === "workspace" + && (effectiveDevice?.capabilities.includes("workspace.exec") ?? false); + const workspaceAccessLabel = effectiveDevice && !effectiveDevice.capabilities.includes("workspace.exec") + ? t("remote.access.workspaceFilesOnly") + : t("remote.access.workspace"); + const availableProfiles = PROFILES.filter(profile => state?.runtimes?.[profile]?.available); + const effectiveProfile = availableProfiles.includes(selectedProfile) + ? selectedProfile + : availableProfiles[0] ?? selectedProfile; + const remoteSessions = state?.sessions ?? []; + const effectiveSession = remoteSessions.find(session => session.id === selectedSessionId) + ?? (localSession && localSession.id === selectedSessionId ? localSession : null) + ?? [...remoteSessions].reverse().find(session => session.status !== "stopped") + ?? localSession; + + const pairingCommands = useMemo(() => { + if (!pairing) return { posix: "", powershell: "" }; + const hub = typeof window === "undefined" ? "https://hub.example" : window.location.origin; + return remoteWorkspacePairingCommands(pairing.code, hub); + }, [pairing]); + + const mutate = async (path: string, init: RequestInit, fallback: string): Promise => { + const response = await fetch(`${apiBase}${path}`, init); + const body = await readJsonOrThrow(response, fallback); + if (body === undefined) throw new Error(fallback); + return body; + }; + + const createPairing = async () => { + setBusy("pair"); + setNotice(null); + try { + const grant = await mutate("/api/remote-workspace/pairing", { method: "POST" }, t("remote.requestFailed")); + setPairing(grant); + setCopiedCommand(null); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const createSession = async () => { + if (!effectiveDevice || !effectiveRoot) return; + setBusy("session"); + setNotice(null); + try { + const session = await mutate("/api/remote-workspace/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: effectiveProfile, + deviceId: effectiveDevice.id, + rootId: effectiveRoot.id, + accessMode: selectedAccessMode, + }), + }, t("remote.requestFailed")); + setLocalSession(session); + setSelectedSessionId(session.id); + setNotice({ tone: "ok", text: t("remote.sessionStarted") }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const sendPrompt = async () => { + if (!effectiveSession || !prompt.trim() || promptPending || stopPending || busy !== null) return; + const target = effectiveSession; + const submitted = prompt; + setPrompt(""); + setPromptPending(true); + setNotice(null); + try { + const session = await mutate(`/api/remote-workspace/sessions/${target.id}/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: submitted }), + }, t("remote.requestFailed")); + if (stoppedSessionId.current !== target.id) setLocalSession(session); + void resource.refresh(); + } catch (error) { + if (stoppedSessionId.current !== target.id) { + setPrompt(submitted); + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } + } finally { setPromptPending(false); } + }; + + const stopSession = async () => { + if (!effectiveSession || stopPending || busy !== null) return; + const target = effectiveSession; + setStopPending(true); + try { + await mutate(`/api/remote-workspace/sessions/${target.id}`, { method: "DELETE" }, t("remote.requestFailed")); + stoppedSessionId.current = target.id; + setLocalSession({ ...target, status: "stopped" }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setStopPending(false); } + }; + + const revokeDevice = async (device: RemoteDevice) => { + if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return; + setBusy("revoke"); + try { + await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed")); + if (selectedDeviceId === device.id) setSelectedDeviceId(""); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const copyPairingCommand = async (kind: "posix" | "powershell", command: string) => { + setCopiedCommand(await copyText(command) ? kind : null); + }; + + if (resource.loading && !state) return
{t("remote.loading")}
; + if (resource.error && !state) { + return <>{t("remote.loadFailed")}; + } + if (state?.available === false) return {t("remote.hubRequired")}; + + return ( +
+
+
+

{t("remote.title")}

+

{t("remote.subtitle")}

+
+ +
+ + {notice ? {notice.text} : null} + +
+
+
+
+
+

{t("remote.addComputer")}

{t("remote.addComputerHint")}

+
+ + {pairing ? ( +
+ {t("remote.pairingCode")} +
{pairing.code}
+
{t("remote.pairingExpires", { time: new Date(pairing.expiresAt).toLocaleTimeString() })}
+ {t("remote.pairingCommandPosix")} +
{pairingCommands.posix}
+ + {t("remote.pairingCommandWindows")} +
{pairingCommands.powershell}
+ +
+ ) : null} +
+ +
+

{t("remote.devices")}

{devices.length}
+ {devices.length === 0 ?

{t("remote.noDevices")}

: ( +
+ {devices.map(device => ( +
+ + +
+ ))} +
+ )} +
+
+ +
+
+

{t("remote.newSession")}

+
+ + +
+ {effectiveDevice ? ( +
+ {PROFILE_LABEL[effectiveProfile]}{t("remote.runsOnHub")} + {effectiveDevice.name}{selectedAccessMode === "read-only" ? t("remote.runsReadOnly") : selectedCanExecute ? t("remote.runsFilesCommands") : t("remote.runsFilesOnly")} +
+ ) : null} + {selectedAccessMode === "workspace" && !selectedCanExecute && effectiveDevice ? {t("remote.execUnavailable")} : null} + {!state?.runtimes?.[effectiveProfile]?.available && state?.runtimes?.[effectiveProfile]?.reason + ?

{state.runtimes[effectiveProfile].reason}

+ : null} + +
+ +
+
+

{t("remote.sessions")}

{effectiveSession ? {PROFILE_LABEL[effectiveSession.profile]} · {effectiveSession.deviceName}/{effectiveSession.rootLabel} · {effectiveSession.accessMode === "read-only" ? t("remote.access.readOnly") : t("remote.access.workspace")} : null}
+ {effectiveSession ? {t(STATUS_TKEY[effectiveSession.status])} : null} +
+ {remoteSessions.length > 1 ? ( +