diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md index 5c126f5987..4cf079534d 100644 --- a/devlog/_plan/260912_remote_workspace_carry/030_integration.md +++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md @@ -67,3 +67,23 @@ 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. + +Source-audit follow-up: pending creation promises and late handle cleanup are owned by shutdown, which settles all session cleanups before propagating failure. Pair-body completion rechecks stop admission. UI derives enrollment commands from the actual shared Hub origin, keeps drafts per session, preserves newer input on failure, shares submission eligibility between keyboard/button, warns on stale snapshots, and permits Stop during pairing. Controlled runtime/body/draft/Stop regressions accompany these adaptations; execution remains hosted-only. diff --git a/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md b/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md new file mode 100644 index 0000000000..0b8bcdec90 --- /dev/null +++ b/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md @@ -0,0 +1,23 @@ +# Resumed final-tip verification + +The integration branch was fast-forwarded from 5c462fec1a to existing remote head 4ad1d1ce54 without rewriting the six intervening commits. PRs #4344, #4362 and #4372 remain open. CI run 34683454076 at 4ad1d1ce54359ee25fbb078344a22bdb16f7e304 failed; cancelled and skipped jobs are not passing evidence. + +## Scoped repair + +MODIFY tests/clients/remote-workspace-activation.test.ts: provide the HTTP Host precondition that managementRequestOrigin requires. Preserve 200/404 expectations and assert the admin-token denial message so a generic origin failure cannot satisfy the consent regression. + +MODIFY tests/clients/remote-workspace-sessions.test.ts: observe pending rejection using native Promise settlement, release the held start/availability gate, then assert rejection. Keep late runtime cleanup, one-stop/one-close checks, terminal failure state, and assert zero model prompts. Production cleanup code is unchanged. The source dependency order is verified; Bun matcher scheduling remains a hypothesis until repaired-head hosted execution confirms it. + +The host goal is observed blocked and the native cycle remains C; no direct goal/FSM edits or reset were performed. This repair continues the incomplete integration Check under explicit resume authorization. It is not claimed as an additional completed cycle. Local suites/build/typecheck/install are NOT RUN. Independent inherited-model review and final cumulative-tip hosted CI remain required. + +## Render provenance + +Dashboard artifact 10294424393 from run 34683454076 records merge-ref commit 5270d422166eb56692d1fde1513d5ff4f40533d4 and GUI tree 69b1bce039ee18cecea2586bc484c2afe0523a13, equal to candidate 4ad1d1ce54:gui. It is rendered with synthetic example state on a loopback static server; mutations are disabled. This proves the captured layout only, not real pairing or runtime execution. + +Observed screenshots: assets/041_desktop_read_only.png and assets/042_desktop_files_only.png at 1440x913 CSS pixels. The actual access picker changes from Read only to Edit files only and displays the command-unavailable notice. No live enrollment or model operation was invoked. A requested390px native-window resize stopped at500px; that is not390px proof, and no narrower layout claim is made. The installed browser CLI does not expose the documented script command; no driver was installed to work around it. + +## Windows subprocess fixtures + +Run34693137770 at d1a922ae reports three remote-owned fixture failures on Windows: two native response cases cannot execute a POSIX shebang file, and the cwd case times out while starting PowerShell. The intended contracts are bounded stdin/response decoding and real child cwd; neither requires a shell. Replace only test subprocess fixtures with the exact current Bun executable, preserve helper-path checks, timeout/output bounds and success/error assertions. Native Windows command support remains disabled. Local suites remain NOT RUN; independent source review and repaired-head hosted proof are required. + +The Windows native fixture keeps descriptor keys separate from its injected spawn function, verifies all pipe options before forwarding them unchanged, and never executes authority from the input JSON. Cwd checks compare decoded stdout with the canonical executor directory and exclude the canonical coordinator directory. An exact exit-code/empty-stderr assertion accompanies the unchanged deadline. At predecessor d1a922ae, hosted Windows2 job103552067478 passed the corrected disabled management status case; that narrow result is not an all-suite pass. diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png b/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png new file mode 100644 index 0000000000..9075e975be Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png differ diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png b/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png new file mode 100644 index 0000000000..c99e88e29f Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png differ 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..4d31a6dee0 --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -0,0 +1,195 @@ +--- +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. + +Prompt submission acknowledges acceptance immediately; the dashboard polls the session for progress and completion. If the acknowledgement is lost, the draft remains visible with an unknown-submission notice. Check session progress before sending it again; the dashboard never retries a prompt automatically. + +**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. + +## Prompt acceptance API + +`POST /api/remote-workspace/sessions/:id/prompt` returns HTTP 202 with the accepted session snapshot. Its session ID and monotonic event sequence identify the acceptance snapshot; 202 does not mean that the model turn completed. Poll `GET /api/remote-workspace/sessions` for later events and terminal status. Reconnect and runtime resume remain busy while that turn is active. A lost acknowledgement leaves acceptance unknown, so clients must poll before choosing whether to submit again. 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..b5e60c87db 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 e2184af3d2..9dbf4d28bc 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 68feff3023..c184db815f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2719,4 +2719,65 @@ 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.submissionUnknown": "Der Übermittlungsstatus ist unbekannt. Prüfen Sie die Sitzung, bevor Sie erneut senden.", + "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 63f54b0c32..10eb37ce10 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2753,6 +2753,67 @@ 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.submissionUnknown": "Submission status is unknown. Check the session before sending again.", + "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 5cf01bcba2..5fc0de4059 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2707,4 +2707,65 @@ 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.submissionUnknown": "Le statut de l’envoi est inconnu. Vérifiez la session avant de renvoyer.", + "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 5971eb8e3c..c39967ada5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2740,4 +2740,65 @@ 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.submissionUnknown": "送信状況を確認できません。再送信する前にセッションを確認してください。", + "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 8ae8f56b79..7d45690b78 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2741,4 +2741,65 @@ 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.submissionUnknown": "전송 여부를 확인하지 못했습니다. 다시 보내기 전에 세션 상태를 확인하세요.", + "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 277cb49175..a6a549f33b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2742,4 +2742,65 @@ 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.submissionUnknown": "Статус отправки неизвестен. Проверьте сеанс перед повторной отправкой.", + "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 f280f08f68..22d4c53dac 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2742,4 +2742,65 @@ 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.submissionUnknown": "Gönderim durumu bilinmiyor. Yeniden göndermeden önce oturumu kontrol edin.", + "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 688cf590f5..352b5d8afa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2705,4 +2705,65 @@ 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.submissionUnknown": "無法確認傳送狀態。請先檢查工作階段,再決定是否重新傳送。", + "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 bcf995f4c1..b69aba3eea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2740,4 +2740,65 @@ 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.submissionUnknown": "无法确认发送状态。请先检查会话,再决定是否重新发送。", + "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..d6eebb3258 --- /dev/null +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -0,0 +1,411 @@ +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, hubOrigin }: { apiBase: string; hubOrigin: 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 [drafts, setDrafts] = 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 fallbackSession = [...remoteSessions].reverse().find(session => session.status !== "stopped"); + const candidate = remoteSessions.find(session => session.id === selectedSessionId) + ?? (localSession?.id === selectedSessionId ? localSession : null) + ?? fallbackSession + ?? localSession; + const wantedSessionId = candidate?.id; + const polledSession = remoteSessions.find(session => session.id === wantedSessionId); + const selectedLocal = localSession?.id === wantedSessionId ? localSession : null; + const selectedSession = selectedLocal && (!polledSession + || (selectedLocal.events.at(-1)?.sequence ?? 0) > (polledSession.events.at(-1)?.sequence ?? 0)) + ? selectedLocal : polledSession; + const effectiveSession = selectedSession + ?? fallbackSession + ?? localSession; + + const prompt = drafts[effectiveSession?.id ?? ""] ?? ""; + const setPrompt = (value: string | ((current: string) => string)) => { + const id = effectiveSession?.id; + if (!id) return; + setDrafts(current => ({ ...current, [id]: typeof value === "function" ? value(current[id] ?? "") : value })); + }; + const stale = Boolean(state && !resource.lastAttemptOk); + const canSend = Boolean(effectiveSession && prompt.trim() && busy === null && !promptPending && !stopPending && !stale + && effectiveSession.status !== "running" && effectiveSession.status !== "starting" + && effectiveSession.status !== "stopped" + && !(effectiveSession.status === "failed" && effectiveSession.resumable === false) + && !(effectiveSession.status === "waiting_for_executor" && !devices.find(device => device.id === effectiveSession.deviceId)?.online)); + + const pairingCommands = useMemo(() => { + if (!pairing) return { posix: "", powershell: "" }; + return remoteWorkspacePairingCommands(pairing.code, hubOrigin); + }, [pairing, hubOrigin]); + + 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 || !canSend) return; + const target = effectiveSession; + const submitted = prompt; + setPromptPending(true); + setNotice(null); + let responseStatus: number | undefined; + try { + const response = await fetch(`${apiBase}/api/remote-workspace/sessions/${target.id}/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: submitted }), + }); + responseStatus = response.status; + const session = await readJsonOrThrow(response, t("remote.requestFailed")); + if (!session) throw new Error(t("remote.requestFailed")); + setPrompt(current => current === submitted ? "" : current); + if (stoppedSessionId.current !== target.id) setLocalSession(session); + void resource.refresh(); + } catch (error) { + if (stoppedSessionId.current !== target.id) { + const rejected = responseStatus !== undefined && responseStatus >= 400 && responseStatus < 500; + setNotice({ tone: "err", text: rejected + ? error instanceof Error ? error.message : t("remote.requestFailed") + : t("remote.submissionUnknown") }); + void resource.refresh(); + } + } finally { setPromptPending(false); } + }; + + const stopSession = async () => { + if (!effectiveSession || stopPending) 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")}

+
+ +
+ + {stale ? {t("remote.loadFailed")} : null} + {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") : effectiveSession.capabilities.includes("workspace.exec") ? t("remote.access.workspace") : t("remote.access.workspaceFilesOnly")} : null}
+ {effectiveSession ? {t(STATUS_TKEY[effectiveSession.status])} : null} +
+ {remoteSessions.length > 1 ? ( +