Repository navigation
feat: connect device hosts over SSH - #394
Conversation
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>
There was a problem hiding this comment.
🟡 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
ensureAgentReadyand installs/starts helpers before failing. This contradicts theagentReadinessIfSupportedcontract 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
hostStatusis only updated forlocal, but the setup wizard gates Continue/Done onstate.hostStatus === "ready". If the only usable platform is on an SSH host, listing setshostStatuses[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
summariescontains both local and SSH hosts, the webplatformSetupStatusstill flattensstate.hostsand 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
connectOncehas already run the remotestartscript but local forwarding or the readiness check fails, this loop only closes the localconnectionScopebefore 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
androidis marked available when onlyadbexists, but discovery later unconditionally runsemulator -list-avdsinDeviceService.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 sendstrueto 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
sshhosts makesDevicePanelpass remote devices toDeviceToolsPanel, butDevicePanelstill resolves its shared hub access withuseDeviceHubAccess(environmentId, "local", ...). The drawer's foreground and event-log requests therefore hit the local hub (or remain unavailable) for remote devices, even thoughDeviceStreamViewcorrectly usesprops.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.
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>
Summary
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, andpnpm typecheckpass; lint retains existing warnings.pnpm testpasses. Final focused follow-up: 70 tests across 14 affected device, connection, and settings suites pass.Evidence
Original implementation pass:
673c9faba526b8f4abb3b3265cdd4534cd0476d0.Original local-only settings and SSH settings captures.
Real Chromium client against two isolated local environments:
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.
pnpm testpassed during this follow-up. After the final probe-refresh refinement, 23 targeted server tests passed; all 8 host-mutation tests passed. Finalpnpm fmt:check,pnpm lint, andpnpm typecheckpassed.Feature captures
PR head at upload:
e126caa25808cac48894489e866ea22e7519c96d. See the testing notes for exercised behavior and limitations; uploading media is not a test result.