Skip to content

feat: connect device hosts over SSH - #394

Merged
ronak-guliani merged 2 commits into
mainfrom
feat/ssh-device-hosts
Sep 16, 2026
Merged

ronak-guliani merged 2 commits into
mainfrom
feat/ssh-device-hosts

Conversation

@ronak-guliani

@ronak-guliani ronak-guliani commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Connect iOS Simulator and Android Emulator hosts over key-based SSH, using the selected environment's SSH configuration. Add, probe, edit, and remove hosts from Settings → Devices.
  • Forward loopback-only Device Hub and agent endpoints through the existing authenticated proxy. Keep agent consent separate, reconnect failed tunnels, isolate slow host startup, and clean up owned helpers without shutting down simulators.
  • Apply host edits to each selected environment's own host list, filter self-connections, and show per-environment connection results. Consume incremental settings events so saved environments reflect changes immediately.

Ports pingdotgg/t3code#10856, with environment-scoping and connection-test behavior from pingdotgg/t3code#11541, pingdotgg/t3code#11698, and pingdotgg/t3code#11699. Adapted to this fork's existing settings and transport APIs rather than importing unrelated upstream infrastructure.

Testing

  • pnpm fmt:check, pnpm lint, and pnpm typecheck pass; lint retains existing warnings.
  • pnpm test passes. Final focused follow-up: 70 tests across 14 affected device, connection, and settings suites pass.
  • Regression coverage includes dropped-tunnel recovery, concurrent host isolation, failed agent activation cleanup, remote script ownership/locking, duplicate device IDs, SSH self-target detection, per-environment host-list preservation, and expected probe failures.

Evidence

Original implementation pass: 673c9faba526b8f4abb3b3265cdd4534cd0476d0.
Original local-only settings and SSH settings captures.

Real Chromium client against two isolated local environments:

  • Validated invalid-target controls, self-host detection, and explicit SSH connection refusal on an unused loopback port.
  • Created, reloaded, edited, and removed host configuration; verified persistence after reload.
  • Tested both environments from one editor, changed SSH options to invalidate old results, and verified per-environment failures.
  • Saved to both environments, edited only environment B, and confirmed environment A remained unchanged.
  • Exercised the editor at a 390px viewport.
  • Final passes had no unexpected console errors or failed requests. Recorded-navigation aborts and trace cancellations following an observed HTTP 204 are accounted for separately.

Collaborative preview initialization/snapshot failed, so feature assertions and screenshots used Playwright Chromium. The normal dev runner also hit its existing task-filter argument issue; the isolated stack used the runner-generated environment with pnpm's workspace filters.

Validation limit: No external SSH simulator host was used. Real remote video, gestures, app installation, and Android boot were not exercised. SSH tunnel recovery and remote helper startup/cleanup were verified with controlled process fixtures, not a physical remote machine.

Floating device previews, app delivery, Metro forwarding, and an end-to-end mobile self-test runner remain outside this PR.

Review follow-up

Tested revision: e126caa258.

  • Skip known unsupported SSH hosts without suppressing initial discovery. Explicit saved-host connection tests refresh cached availability after toolchain changes.
  • Persist the current agent endpoint under the adapter's reconnect lock, with host identity and access rechecked under the service lifecycle lock.
  • Confirm SSH destinations before trusting environment-local host IDs; preserve retries and reject unrelated insertion collisions.
  • pnpm test passed during this follow-up. After the final probe-refresh refinement, 23 targeted server tests passed; all 8 host-mutation tests passed. Final pnpm fmt:check, pnpm lint, and pnpm typecheck passed.
  • Final Chromium pass repeated the two-environment connection/scoped-edit flow and then seeded intentionally colliding host IDs. An all-environment edit changed only the two matching destinations and retained their local IDs; removal deleted those destinations but preserved the unrelated host, including after reload. Runtime API assertions confirmed exact persisted host lists.
  • The captures below show the collision fixture before editing, after editing, and after removal/reload. Final console errors: 0. Unexpected failed requests: 0. Collaborative snapshots still timed out, so this pass used Playwright Chromium.
  • Remote simulator hardware remains untested; endpoint/reconnect and revocation races use deterministic process/service fixtures.

Feature captures

PR head at upload: e126caa25808cac48894489e866ea22e7519c96d. See the testing notes for exercised behavior and limitations; uploading media is not a test result.

device-host-collision-before.png

device-host-collision-edit.png

device-host-collision-remove.png

Port upstream SSH device hosts with environment-scoped configuration, self-host filtering, and per-host lifecycle recovery. Keep saved environment settings live and expose non-installing connection checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings September 15, 2026 23:58
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Sep 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical and moderate issues remain in SSH lifecycle, readiness aggregation, cross-environment host mutations, mixed-state settings, and remote device hub routing.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds SSH-backed iOS Simulator and Android Emulator hosts with environment-scoped settings, probing, tunneling, lifecycle management, and documentation.

Changes:

  • Adds SSH contracts, RPCs, validation, and server-side host management.
  • Adds multi-environment Settings UI and live synchronization.
  • Adds remote helper scripts, recovery behavior, tests, and device documentation.
File summaries
File Reviewed change
packages/shared/src/serverSettings.test.ts Tests settings replacement behavior.
packages/contracts/src/settings.ts Defines persisted device-host settings.
packages/contracts/src/settings.test.ts Tests host-setting validation.
packages/contracts/src/rpc.ts Defines host-testing RPC contracts.
packages/contracts/src/device.ts Defines SSH host contracts and summaries.
packages/client-runtime/src/state/device.ts Adds runtime host-test commands.
docs/user/devices.md Documents remote device hosts.
docs/internals/devices.md Documents SSH device architecture.
apps/web/src/state/device.ts Adds web host-test commands.
apps/web/src/state/device.test.tsx Tests client probe failures.
apps/web/src/rpc/wsRpcClient.ts Exposes the host-test RPC.
apps/web/src/environments/runtime/service.ts Updates saved environment settings.
apps/web/src/environments/runtime/connection.ts Consumes settings events.
apps/web/src/environments/runtime/connection.test.ts Tests settings synchronization.
apps/web/src/components/settings/useHostConnectionChecks.ts Integrates connection checks.
apps/web/src/components/settings/SettingsPanels.tsx Mounts device settings.
apps/web/src/components/settings/DeviceSettings.tsx Provides multi-environment device settings.
apps/web/src/components/settings/deviceHostsSettings.logic.ts Applies host-list mutations.
apps/web/src/components/settings/deviceHostsSettings.logic.test.ts Tests host-list preservation.
apps/web/src/components/settings/DeviceHostEditor.tsx Provides host editing and probing.
apps/web/src/components/settings/deviceHostConnectionChecks.ts Handles connection results.
apps/web/src/components/settings/deviceHostConnectionChecks.test.ts Tests connection-check behavior.
apps/web/src/components/device/DeviceHostAvailability.tsx Displays platform availability.
apps/server/src/ws.ts Registers RPCs and filters settings.
apps/server/src/mcp/toolkits/device/handlers.ts Reports host-aware device status.
apps/server/src/mcp/McpDeviceToolkit.test.ts Tests MCP host behavior.
apps/server/src/device/sshDeviceScript.ts Bootstraps remote helpers.
apps/server/src/device/sshDeviceScript.test.ts Tests remote scripts.
apps/server/src/device/SshDeviceHost.ts Implements SSH forwarding and recovery.
apps/server/src/device/SshDeviceHost.test.ts Tests SSH lifecycle behavior.
apps/server/src/device/sshCommand.ts Executes SSH commands.
apps/server/src/device/localSshDeviceHost.ts Filters self-targeted SSH hosts.
apps/server/src/device/localSshDeviceHost.test.ts Tests self-host detection.
apps/server/src/device/LocalDeviceHost.ts Resolves local device tools.
apps/server/src/device/DeviceToolchain.ts Manages device tool installation.
apps/server/src/device/DeviceService.ts Coordinates host lifecycle and readiness.
apps/server/src/device/DeviceService.test.ts Tests device service behavior.
apps/server/src/device/DeviceMultiHost.test.ts Tests multi-host isolation.
apps/server/src/device/DeviceHost.ts Defines host abstraction and readiness.
apps/server/src/device/DeviceActions.ts Runs host-specific device actions.
apps/server/src/device/DeviceActions.test.ts Tests host-based actions.
apps/server/src/auth/RpcAuthorization.ts Authorizes host-testing RPCs.
.agents/references/scars/full.md Records SSH lifecycle invariants.
Review details

Suppressed comments (8)

apps/server/src/device/DeviceService.ts:355

  • The agent variant has the same platform-kind check, so after a remote host has reported both platforms unavailable, enabling agent access still runs ensureAgentReady and installs/starts helpers before failing. This contradicts the agentReadinessIfSupported contract and can repeat the expensive failure on every agent readiness request; apply the same probed-versus-unprobed handling here.
      if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
        return null;

apps/server/src/device/DeviceService.ts:299

  • hostStatus is only updated for local, but the setup wizard gates Continue/Done on state.hostStatus === "ready". If the only usable platform is on an SSH host, listing sets hostStatuses[ssh] to ready while this aggregate remains idle, so first-time setup cannot be completed for remote-only environments. Derive setup readiness from the per-host statuses or update an aggregate when any host is ready.
            ...(hostId === LOCAL_DEVICE_HOST_ID
              ? { hostStatus: status.status, hostStatusDetail: status.detail }
              : {}),
            hostStatuses: { ...state.hostStatuses, [hostId]: status },

apps/server/src/device/DeviceService.ts:934

  • Once summaries contains both local and SSH hosts, the web platformSetupStatus still flattens state.hosts and takes the first matching platform. A local host with no iOS/Android toolchain therefore masks an available platform on a later SSH host and setup reports it unavailable. Aggregate matching entries, such as by checking whether any candidate is available, before presenting setup status.
        hosts: summaries,

apps/server/src/device/SshDeviceHost.ts:346

  • If connectOnce has already run the remote start script but local forwarding or the readiness check fails, this loop only closes the local connectionScope before retrying. On the final failure the detached remote hub/agent remains owned and running while the host stays configured, because no remote stop is attempted unless the host is later torn down. Clean up the owned remote helpers after giving up (without interrupting the existing retry path).
      const result = yield* connectOnce().pipe(Effect.result);
      if (result._tag === "Success") return result.success;
      const failedScope = connectionScope;
      connectionScope = null;
      if (failedScope) yield* Scope.close(failedScope, Exit.void);

apps/server/src/device/sshDeviceScript.ts:106

  • android is marked available when only adb exists, but discovery later unconditionally runs emulator -list-avds in DeviceService.fetchDevices. A remote host with platform-tools but no Emulator is therefore reported as supported and then its first listing fails/marks the host failed. Probe the emulator executable as well.
  const android = run('adb', ['version']).status === 0;

apps/web/src/components/settings/DeviceSettings.tsx:249

  • When the selected environments disagree, every(...) makes this aggregate switch look simply unchecked, so “some enabled” is indistinguishable from “all disabled” before the next click. Represent the mixed state for the All environments selection; clicking it currently sends true to every target without showing that it is overwriting a mixed configuration.
            checked={ready && targets.every((item) => item.config?.settings.enableDeviceSupport)}

apps/web/src/components/settings/DeviceSettings.tsx:265

  • The agent-access aggregate has the same problem: every(...) renders unchecked when some selected environments allow agent access and others do not. This hides the mixed state and makes the next click appear to enable a disabled setting rather than overwrite per-environment values; expose the standard mixed state when selecting All environments.
              ready && targets.every((item) => item.config?.settings.enableAgentDeviceAccess)
            }

packages/contracts/src/device.ts:78

  • Adding ssh hosts makes DevicePanel pass remote devices to DeviceToolsPanel, but DevicePanel still resolves its shared hub access with useDeviceHubAccess(environmentId, "local", ...). The drawer's foreground and event-log requests therefore hit the local hub (or remain unavailable) for remote devices, even though DeviceStreamView correctly uses props.hostId; resolve this access from the active device/target host.
  • Files reviewed: 43/43 changed files
  • Comments generated: 3
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/server/src/device/DeviceService.ts Outdated
Comment thread apps/web/src/components/settings/deviceHostsSettings.logic.ts
Comment thread apps/server/src/device/DeviceService.ts Outdated
Skip known unsupported hosts while preserving discovery and explicit probe refresh. Persist current agent endpoints under the adapter lifecycle lock and recheck access before writing. Match cross-environment hosts by destination before trusting local IDs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ronak-guliani
ronak-guliani merged commit 226f5cf into main Sep 16, 2026
6 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants