From d2eeacd8ccde4d8763ca315b4cddf2964db93329 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:59:34 -0700 Subject: [PATCH] feat(devices): connect simulator hosts over SSH (#10856) --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/device/DeviceActions.test.ts | 1 + apps/server/src/device/DeviceActions.ts | 2 +- apps/server/src/device/DeviceHost.ts | 3 +- .../server/src/device/DeviceMultiHost.test.ts | 54 ++- apps/server/src/device/DeviceService.test.ts | 1 + apps/server/src/device/DeviceService.ts | 272 ++++++++--- apps/server/src/device/DeviceToolchain.ts | 4 +- apps/server/src/device/LocalDeviceHost.ts | 1 + apps/server/src/device/SshDeviceHost.test.ts | 131 ++++++ apps/server/src/device/SshDeviceHost.ts | 434 ++++++++++++++++++ .../server/src/device/sshDeviceScript.test.ts | 220 +++++++++ apps/server/src/device/sshDeviceScript.ts | 192 ++++++++ apps/server/src/mcp/McpDeviceToolkit.test.ts | 1 + .../src/mcp/toolkits/device/handlers.ts | 3 + apps/server/src/ws.ts | 4 + .../device/DeviceHostAvailability.tsx | 27 ++ .../settings/DeviceHostsSettings.tsx | 334 ++++++++++++++ .../settings/IntegrationsSettings.tsx | 72 ++- .../src/components/settings/settingsSearch.ts | 6 + docs/internals/devices.md | 12 +- docs/user/devices.md | 29 +- packages/client-runtime/src/state/device.ts | 4 + packages/contracts/src/device.ts | 24 +- packages/contracts/src/rpc.ts | 10 + packages/contracts/src/settings.test.ts | 13 + packages/contracts/src/settings.ts | 3 + packages/shared/src/serverSettings.test.ts | 10 + pnpm-lock.yaml | 3 + 30 files changed, 1794 insertions(+), 78 deletions(-) create mode 100644 apps/server/src/device/SshDeviceHost.test.ts create mode 100644 apps/server/src/device/SshDeviceHost.ts create mode 100644 apps/server/src/device/sshDeviceScript.test.ts create mode 100644 apps/server/src/device/sshDeviceScript.ts create mode 100644 apps/web/src/components/device/DeviceHostAvailability.tsx create mode 100644 apps/web/src/components/settings/DeviceHostsSettings.tsx diff --git a/apps/server/package.json b/apps/server/package.json index 7d27d39d2eb3228be05db6c9039f033f19242272..84a8c6a5988bb6746484dee8006ed86fac106e98 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 97afa4f4077526b8bfc3435764f9211fe7be1f81..d7d1be455fa1295d1951c23a41d09cd477eab3b9 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope, [WS_METHODS.deviceList]: AuthOrchestrationReadScope, [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index ed6b5a87335d4f63d9784222946fe4303b47f06e..c22dcfe866100877928104588cd347ee3f577715 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -16,6 +16,7 @@ const makeReady = ( ) => { const calls: Call[] = []; const ready: DeviceHostReady = { + nodePath: process.execPath, hub: { origin: "http://127.0.0.1:1" }, helpers, run: (command, args, options) => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 12781b31b4b55a5216327f79cb0012ff1880b3b1..509dac526b93290854cae1cbaa4009b135e41123 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -327,7 +327,7 @@ const serveSimPermissions = ( reason: "helper_missing", }); yield* ready - .run(process.execPath, [ + .run(ready.nodePath, [ cli, "permissions", input.decision, diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 8d1bafbc5dfcc0050691b39785dcb90dba4b9bcd..34ff2769362efef5c3d1ec6e68a1b9fa438d42c2 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -46,11 +46,12 @@ export interface DeviceHubEndpoint { export interface AgentDeviceEndpoint { readonly baseUrl: string; readonly token: string; - /** Absolute path of the agent-device entry script for the provider PATH shim. */ + /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */ readonly entryPath: string; } export interface DeviceHostReady { + readonly nodePath: string; readonly hub: DeviceHubEndpoint; /** * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index d586dffa156769f3cf73a04842b7706c41360ef3..7a40e1e53c72e9da1f36d462c21ace2098c31cd4 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -1,5 +1,7 @@ import { expect, it } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", Effect.gen(function* () { const host = (id: string, failed = false): DeviceHost["Service"] => { const ready = { + nodePath: process.execPath, hub: { origin: `http://${id}` }, agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), @@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", summary: Effect.succeed({ id, label: id, - kind: "local", + kind: id === "b" ? "ssh" : "local", hubInstalled: true, agentDeviceInstalled: true, - platforms: [{ platform: "android", available: true }], + platforms: id === "b" ? [] : [{ platform: "android", available: true }], }), platformAvailability: (platform) => Effect.succeed({ platform, available: true }), ensureReady: () => @@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails", ), ); const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); - const service = yield* makeWithHosts(hosts).pipe( - Effect.provideService(HttpClient.HttpClient, http), - ); + const writeStarted = yield* Deferred.make(); + const finishWrite = yield* Deferred.make(); + const order: string[] = []; + const service = yield* makeWithHosts(hosts, undefined, () => + Effect.gen(function* () { + order.push("write started"); + yield* Deferred.succeed(writeStarted, undefined); + yield* Deferred.await(finishWrite); + order.push("write finished"); + return "/host-config.json"; + }), + ).pipe(Effect.provideService(HttpClient.HttpClient, http)); + expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull(); const listed = yield* service.list; expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); expect(listed.hostStatuses.offline?.status).toBe("failed"); @@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails", expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); expect(state.hostStatuses.a?.status).toBe("ready"); expect(state.hostStatuses.offline?.status).toBe("failed"); - yield* service.agentReadinessIfSupported("b"); - expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); + const targeting = yield* service + .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" }) + .pipe(Effect.forkChild); + yield* Deferred.await(writeStarted); + const replacing = yield* service + .withLifecycleLock( + Effect.gen(function* () { + order.push("replace"); + hosts.set("b", host("b")); + yield* service.refreshHosts; + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.succeed(finishWrite, undefined); + yield* Fiber.join(targeting); + yield* Fiber.join(replacing); + expect(order).toEqual(["write started", "write finished", "replace"]); + const replaced = yield* service.state; + expect(replaced.sessions).toEqual([]); + expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]); + expect(replaced.hostStatuses.b).toBeUndefined(); + yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" }); + hosts.delete("b"); + yield* service.refreshHosts; + yield* service.setHostStatus("b", { status: "ready" }); + expect((yield* service.state).hostStatuses.b).toBeUndefined(); + expect((yield* service.state).sessions).toEqual([]); + yield* service.agentReadinessIfSupported("a"); + expect((yield* service.state).hostStatuses.a?.status).toBe("ready"); yield* service.configure({ enabled: false }); expect((yield* service.state).hostStatuses).toEqual({}); }).pipe( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index b45db236fab6875940de3f9d5ec43f93b39c262c..46e159dbfc3307f7b12a1db1d24a6487a16262e7 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( let booted = false; let shutDown = false; const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, hub: { origin: "http://device.test" }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 866663dbf21cfd27fc8ba8cbfbc9346f54c918a0..a60cda76c6481c070440d6e8a0060bddeb0bf328 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -30,6 +30,8 @@ import { type DeviceSession, type DeviceShutdownInput, type DeviceSummary, + type SshDeviceHostConfig, + type DeviceHostSummary, LOCAL_DEVICE_HOST_ID, type ThreadId, } from "@t3tools/contracts"; @@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import * as SshDeviceHost from "./SshDeviceHost.ts"; +import * as Exit from "effect/Exit"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; /** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */ @@ -105,6 +109,9 @@ export class DeviceService extends Context.Service< DeviceService, { readonly agentCli: Effect.Effect; + readonly testHost: ( + config: SshDeviceHostConfig, + ) => Effect.Effect; readonly agentTarget: (input: { threadId: ThreadId; hostId: DeviceHostId; @@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) => export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, + testHost: DeviceService["Service"]["testHost"] = (host) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: host.id, + reason: "SSH probing is unavailable in this device service.", + }), + ), configureAgent: ( hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady, @@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const statePubSub = yield* PubSub.unbounded(); const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); + let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { hosts: initialHosts, @@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hostId: DeviceHostId, status: DeviceServiceState["hostStatuses"][string], ) => - publish((state) => ({ - ...state, - ...(hostId === LOCAL_DEVICE_HOST_ID - ? { hostStatus: status.status, hostStatusDetail: status.detail } - : {}), - hostStatuses: { ...state.hostStatuses, [hostId]: status }, - })); + Effect.suspend(() => + !hosts.has(hostId) + ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)) + : publish((state) => ({ + ...state, + ...(hostId === LOCAL_DEVICE_HOST_ID + ? { hostStatus: status.status, hostStatusDetail: status.detail } + : {}), + hostStatuses: { ...state.hostStatuses, [hostId]: status }, + })), + ); const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( function* (hostId) { @@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), ), ); + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); const { state } = yield* SynchronizedRef.get(stateRef); if (state.hostStatuses[host.id]?.status !== "ready") { yield* setHostStatus(host.id, { status: "ready" }); @@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!(yield* readDeviceSettings).enabled) return null; const host = yield* resolveHost(hostId); const summary = yield* host.summary; - if (!summary.platforms.some((platform) => platform.available)) return null; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; return yield* readiness(host.id); }); @@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; const host = yield* resolveHost(hostId); const summary = yield* host.summary; - if (!summary.platforms.some((platform) => platform.available)) return null; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; const ready = yield* host .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) .pipe( @@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) { + const host = hosts.get(ready.hostId); const { devices, detail } = yield* fetchDevices(ready); const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled) + if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, @@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), ); } + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); const openedAt = DateTime.formatIso(yield* DateTime.now); const session: DeviceSession = { threadId: input.threadId, @@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), ); - return DeviceService.of({ - agentCli: Effect.fail( - new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - reason: "Agent CLI installation is unavailable in this device service.", - }), - ), - agentTarget: (input) => - Effect.gen(function* () { - const ready = yield* agentReadinessIfSupported(input.hostId); - if (!ready) - return yield* new DeviceHostUnavailableError({ - hostId: input.hostId, - reason: - "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", - }); - const configPath = yield* configureAgent(input.hostId, ready); - return [ - "--config", - configPath, - "--session", - agentDeviceSession(input.threadId, input.hostId, input.deviceId), - ]; - }), - state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), - subscribe: PubSub.subscribe(statePubSub), - configure, - list, - open, - close, - shutdown, - detail, - action, - screenshot, - readiness, - readinessIfSupported, - agentReadinessIfSupported, - currentReadiness, - sessionsForThread, - }); + return { + ...DeviceService.of({ + testHost, + agentCli: Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + reason: "Agent CLI installation is unavailable in this device service.", + }), + ), + agentTarget: (input) => + Effect.gen(function* () { + const host = yield* resolveHost(input.hostId); + const ready = yield* agentReadinessIfSupported(input.hostId); + if (!ready) + return yield* new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: + "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", + }); + const configPath = yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); + return yield* configureAgent(input.hostId, ready); + }), + ); + return [ + "--config", + configPath, + "--session", + agentDeviceSession(input.threadId, input.hostId, input.deviceId), + ]; + }), + state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), + subscribe: PubSub.subscribe(statePubSub), + configure, + list, + open, + close, + shutdown, + detail, + action, + screenshot, + readiness, + readinessIfSupported, + agentReadinessIfSupported, + currentReadiness, + sessionsForThread, + }), + setHostStatus, + withLifecycleLock: lifecycleLock.withPermit, + refreshHosts: Effect.gen(function* () { + const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); + const unchanged = (id: DeviceHostId) => + hosts.has(id) && hosts.get(id) === publishedHosts.get(id); + yield* publish((state) => ({ + ...state, + hosts: summaries, + hostStatuses: Object.fromEntries( + Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)), + ), + devices: state.devices.filter((device) => unchanged(device.hostId)), + sessions: state.sessions.filter((session) => unchanged(session.hostId)), + })); + publishedHosts = new Map(hosts); + }), + }; }); /** @public Service construction is part of the canonical Effect module API. */ @@ -807,7 +869,12 @@ export const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; - const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => { + const settings = yield* ServerSettings.ServerSettingsService; + const scope = yield* Scope.Scope; + const hosts = new Map([ + [localHost.id, localHost], + ]); + const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => { const file = agentDeviceConfigPath(config.stateDir, hostId, path); return writeAgentDeviceConfig(file, ready.agentDevice).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -822,7 +889,108 @@ export const make = Effect.gen(function* () { ), Effect.as(file), ); - }); + }; + const probeContext = + yield* Effect.context>>(); + const service = yield* makeWithHosts( + hosts, + (host) => + SshDeviceHost.probe(host).pipe( + Effect.provide(probeContext), + Effect.mapError( + (error) => + new DeviceOperationError({ + operation: "probe host", + reason: "request_failed", + cause: error, + }), + ), + ), + configureAgent, + ); + const hostContext = + yield* Effect.context>>(); + const configured = new Map(); + const reconcile = (next: ReadonlyArray) => + Effect.gen(function* () { + const removed = yield* service.withLifecycleLock( + Effect.gen(function* () { + const removed: Array<{ id: string; scope: Scope.Closeable }> = []; + for (const [id, previous] of configured) { + if ( + next.some( + (host) => + host.id === id && + host.label === previous.config.label && + host.target === previous.config.target && + host.port === previous.config.port && + host.identityFile === previous.config.identityFile, + ) + ) + continue; + hosts.delete(id); + configured.delete(id); + removed.push({ id, scope: previous.scope }); + } + yield* service.refreshHosts; + return removed; + }), + ); + // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts. + yield* Effect.forEach( + removed, + ({ id, scope }) => + Effect.gen(function* () { + yield* Scope.close(scope, Exit.void); + yield* fs + .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true }) + .pipe(Effect.ignore); + }), + { concurrency: 4, discard: true }, + ); + yield* service.withLifecycleLock( + Effect.gen(function* () { + for (const host of next) { + if (configured.has(host.id)) continue; + const hostScope = yield* Scope.fork(scope); + const instance = yield* SshDeviceHost.make( + host, + (ready) => + configureAgent(host.id, ready).pipe( + Effect.asVoid, + Effect.mapError( + (error) => + new DeviceHost.DeviceHostError({ + hostId: host.id, + step: "configuring agent access", + cause: error, + }), + ), + ), + (status, detail) => + service + .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) }) + .pipe(Effect.asVoid), + ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext)); + hosts.set(host.id, instance); + configured.set(host.id, { config: host, scope: hostScope }); + } + yield* service.refreshHosts; + }), + ); + }); + const changes = yield* settings.subscribeChanges; + yield* reconcile((yield* settings.getSettings).deviceHosts); + yield* changes.pipe( + Stream.runForEach((value) => reconcile(value.deviceHosts)), + Effect.forkIn(scope), + ); + yield* Effect.addFinalizer(() => + Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), { + discard: true, + concurrency: 4, + }), + ); return { ...service, agentCli: ensureAgentDevice(config.baseDir).pipe( diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index e6b43cd81ee813f5e64a6a5a777492659bbfd099..fa8d8cd11d173aa4421b0ca5e992a15dc10c25c4 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -const DEVICE_HUB_VERSION = "0.9.0"; +export const DEVICE_HUB_VERSION = "0.9.0"; const AGENT_DEVICE_PACKAGE = "agent-device"; -const AGENT_DEVICE_VERSION = "0.20.10"; +export const AGENT_DEVICE_VERSION = "0.20.10"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b24ebfbd98b6bd21c01516c9cd1f87cb7361bf8c..5a18d6e826a62342b4b38394039ad59ceff446c0 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, + nodePath: process.execPath, run, helpers: running.helpers, }); diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d968d3383fae8f457f86bb07089dcff3a712a68e --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -0,0 +1,131 @@ +// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout. +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Net from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ServerConfig from "../config.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import * as SshDeviceHost from "./SshDeviceHost.ts"; + +it.effect("preserves installed status after probes and cleans failed agent activation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const modes: string[] = []; + let forwards = 0; + let failForward = true; + let rejectConfig = true; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + const forwarding = command.args.includes("-N"); + let output = ""; + if (forwarding) { + if (failForward) { + failForward = false; + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "ChildProcess", + method: "spawn", + description: "Port already bound", + }); + } + forwards++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + forwards--; + }), + ); + } else { + const stdin = command.options.stdin; + if ( + !stdin || + typeof stdin !== "object" || + !("stream" in stdin) || + !Stream.isStream(stdin.stream) + ) + return yield* Effect.die("Missing script"); + const script = yield* stdin.stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (a, b) => a + b, + ), + ); + const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; + modes.push(mode); + output = JSON.stringify({ + nodePath: "/node", + platforms: [{ platform: "ios", available: true }], + hubPort: 1234, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + ...(mode === "agent-start" + ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" } + : {}), + }); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(output)), + stderr: Stream.empty, + all: Stream.empty, + exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(forwarding), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + const host = yield* SshDeviceHost.make( + { id: "test", label: "Test", target: "test.example" }, + () => + rejectConfig + ? Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: "test", + step: "configuring agent access", + cause: new Error("fixture failure"), + }), + ) + : Effect.void, + ).pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + ), + ), + ); + yield* host.ensureReady(() => Effect.void); + expect(forwards).toBe(1); + expect(modes.filter((mode) => mode === "start")).toHaveLength(2); + yield* host.platformAvailability("ios"); + expect((yield* host.summary).hubInstalled).toBe(true); + const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result); + expect(failed._tag).toBe("Failure"); + expect(forwards).toBe(0); + expect(modes.at(-1)).toBe("stop-agent"); + expect(yield* host.current).toBeNull(); + rejectConfig = false; + yield* host.ensureAgentReady(() => Effect.void); + yield* host.platformAvailability("ios"); + expect((yield* host.summary).agentDeviceInstalled).toBe(true); + yield* host.stopAgent; + expect(forwards).toBe(1); + yield* host.stop; + expect(forwards).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ccda0fdefe97649135bb9f8589f790fc87543cd --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.ts @@ -0,0 +1,434 @@ +import * as NodeCrypto from "node:crypto"; +import { + type DeviceHostSummary, + DevicePlatformAvailability, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; +import * as NetService from "@t3tools/shared/Net"; +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; +import * as Exit from "effect/Exit"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ServerConfig from "../config.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; + +const Probe = Schema.Struct({ + nodePath: Schema.String, + platforms: Schema.Array(DevicePlatformAvailability), +}); +const Started = Schema.Struct({ + ...Probe.fields, + hubPort: Schema.Int, + daemonPort: Schema.optionalKey(Schema.Int), + token: Schema.optionalKey(Schema.String), + entryPath: Schema.optionalKey(Schema.String), + helpers: Schema.Struct({ + serveSimAxSettings: Schema.NullOr(Schema.String), + serveSimCli: Schema.NullOr(Schema.String), + }), +}); +const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe)); +const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started)); +const targetFor = (config: SshDeviceHostConfig) => ({ + alias: config.target, + hostname: config.target, + username: null, + port: config.port ?? null, +}); +const identityArgs = (config: SshDeviceHostConfig) => + config.identityFile ? ["-i", config.identityFile] : []; +const commandArgs = (script: string) => [ + "sh", + "-c", + quoteRemoteArg(remoteDeviceEnvironment + script), +]; +const bootstrap = ( + config: SshDeviceHostConfig, + owner: string, + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", +) => + runSshCommand(targetFor(config), { + preHostArgs: identityArgs(config), + remoteCommandArgs: commandArgs( + 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node', + ), + stdin: remoteDeviceScript(owner, mode), + timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, + }).pipe( + Effect.mapError( + (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }), + ), + ); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { + const result = yield* bootstrap(config, "probe", "probe"); + const value = yield* decodeProbe(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), + ), + ); + return { + id: config.id, + label: config.label, + kind: "ssh", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: value.platforms, + } satisfies DeviceHostSummary; +}); + +export const make = Effect.fn("SshDeviceHost.make")(function* ( + config: SshDeviceHostConfig, + onReady: ( + ready: DeviceHost.DeviceHostAgentReady, + ) => Effect.Effect = () => Effect.void, + onStatus: ( + status: "starting" | "ready" | "failed", + detail?: string, + ) => Effect.Effect = () => Effect.void, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const server = yield* ServerConfig.ServerConfig; + const net = yield* NetService.NetService; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const parentScope = yield* Scope.Scope; + const ssh = yield* resolveSshCommand; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + const owner = NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${config.id}`) + .digest("hex") + .slice(0, 24); + const provide = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const lock = yield* Semaphore.make(1); + let stopped = false; + let activated = false; + let wantsAgent = false; + let ready: + | (DeviceHost.DeviceHostReady & { + agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"]; + }) + | null = null; + let connectionScope: Scope.Closeable | null = null; + let summary: DeviceHostSummary = { + id: config.id, + label: config.label, + kind: "ssh", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: [], + }; + + const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => + provide( + runSshCommand(targetFor(config), { + preHostArgs: identityArgs(config), + remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`), + ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), + ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + }), + ).pipe( + Effect.map((result) => ({ ...result, code: 0 })), + Effect.catch((error) => + Effect.succeed({ + stdout: "stdout" in error ? (error.stdout ?? "") : "", + stderr: error.message, + code: "exitCode" in error ? (error.exitCode ?? 127) : 127, + }), + ), + ); + + const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< + DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, + DeviceHost.DeviceHostError + > { + activated = true; + const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); + yield* onStatus("starting"); + const remote = yield* decodeStarted(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reading host endpoints", + cause, + }), + ), + ); + summary = { + ...summary, + platforms: remote.platforms, + hubInstalled: true, + agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, + }; + const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reserving hub port", + cause, + }), + ), + ); + const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reserving daemon port", + cause, + }), + ), + ); + const scope = yield* Scope.make(); + connectionScope = scope; + const child = yield* spawner + .spawn( + ChildProcess.make( + ssh, + [ + ...baseSshArgs(targetFor(config), { batchMode: "yes" }), + ...identityArgs(config), + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ServerAliveInterval=10", + "-o", + "ServerAliveCountMax=3", + "-N", + "-L", + `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`, + ...(remote.daemonPort === undefined + ? [] + : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]), + config.target, + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), + ), + ); + let stderr = ""; + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.sync(() => { + stderr = (stderr + chunk).slice(-2000); + }), + ), + Effect.forkIn(scope), + ); + const next = { + nodePath: remote.nodePath, + hub: { origin: `http://127.0.0.1:${hubPort}` }, + ...(remote.daemonPort !== undefined && + remote.token !== undefined && + remote.entryPath !== undefined + ? { + agentDevice: { + baseUrl: `http://127.0.0.1:${daemonPort}`, + token: remote.token, + entryPath: remote.entryPath, + }, + } + : {}), + helpers: remote.helpers, + run, + }; + for (const [baseUrl, route] of [ + [next.hub.origin, "/readyz"], + ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []), + ]) { + yield* waitForHttpReady({ + baseUrl: baseUrl!, + path: route!, + timeoutMs: 15000, + makeError: () => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "waiting for SSH forward", + cause: new Error(stderr || "Forwarded endpoint did not answer."), + }), + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + } + if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); + ready = next; + yield* onStatus("ready"); + // Reconnect also repairs helpers that died while SSH itself stayed connected. + const unhealthy = Effect.gen(function* () { + while (true) { + yield* Effect.sleep("10 seconds"); + const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe( + Effect.timeout("5 seconds"), + Effect.map((r) => r.status === 200), + Effect.orElseSucceed(() => false), + ); + const daemonAlive = next.agentDevice + ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe( + Effect.timeout("5 seconds"), + Effect.map((r) => r.status === 200), + Effect.orElseSucceed(() => false), + ) + : true; + if (!alive || !daemonAlive) return; + } + }); + yield* Effect.gen(function* () { + yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy); + if (stopped || connectionScope !== scope) return; + ready = null; + yield* onStatus("starting", "Reconnecting to device host…"); + yield* Scope.close(scope, Exit.void); + let delay = 1000; + while (true) { + if (stopped || connectionScope !== scope) return; + yield* Effect.sleep(delay); + const result = yield* lock + .withPermit( + Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))), + ) + .pipe(Effect.result); + if (result._tag === "Success") return; + yield* onStatus("failed", result.failure.message); + if (connectionScope && connectionScope !== scope) + yield* Scope.close(connectionScope, Exit.void); + connectionScope = scope; + delay = Math.min(delay * 2, 30000); + } + }).pipe(Effect.forkIn(parentScope)); + return next; + }); + + const connect = Effect.fn("SshDeviceHost.connect")(function* () { + for (let attempt = 0; ; attempt++) { + 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); + // SSH binds after the reservation is released, so a competing bind needs fresh ports. + if ( + attempt >= 2 || + !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step) + ) + return yield* result.failure; + } + }); + + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => + lock.withPermit( + Effect.gen(function* () { + stopped = false; + if (ready) return ready; + summary = yield* provide(probe(config)); + yield* onPhase("installing"); + return yield* connect().pipe( + Effect.tapError(() => + connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, + ), + ); + }), + ); + const stop = lock.withPermit( + Effect.gen(function* () { + stopped = true; + ready = null; + if (connectionScope) yield* Scope.close(connectionScope, Exit.void); + connectionScope = null; + if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore); + activated = false; + wantsAgent = false; + }), + ); + const changeAgent = (enabled: boolean) => + lock.withPermit( + Effect.gen(function* () { + wantsAgent = enabled; + if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice }; + if (!enabled && !ready?.agentDevice) return null; + ready = null; + const previousScope = connectionScope; + connectionScope = null; + if (previousScope) yield* Scope.close(previousScope, Exit.void); + if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent")); + return yield* connect().pipe( + Effect.onError(() => + Effect.gen(function* () { + const failedScope = connectionScope; + connectionScope = null; + if (failedScope) yield* Scope.close(failedScope, Exit.void); + if (enabled) + yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore); + }), + ), + ); + }), + ); + yield* Effect.addFinalizer(() => stop); + return { + id: config.id, + summary: Effect.sync(() => summary), + current: Effect.sync(() => ready), + ensureReady, + ensureAgentReady: (onPhase) => + onPhase("installing").pipe( + Effect.flatMap(() => changeAgent(true)), + Effect.flatMap((value) => + value?.agentDevice + ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) + : Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "starting agent tools", + cause: new Error("Daemon endpoint missing"), + }), + ), + ), + ), + stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), + stop, + platformAvailability: (platform) => + provide(probe(config)).pipe( + Effect.map((value) => { + summary = { ...summary, platforms: value.platforms }; + return value.platforms.find((p) => p.platform === platform)!; + }), + Effect.orElseSucceed(() => ({ + platform, + available: false, + reason: "Cannot reach device host. Test its SSH connection in Settings.", + })), + ), + } satisfies DeviceHost.DeviceHost["Service"]; +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eadd85fd047d9686b116d23b75371193bb8f4768 --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes. +import * as Effect from "effect/Effect"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +it.effect("finds Android Studio Java for a non-interactive SSH session", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-")); + try { + const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr"); + await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(javaHome, "bin/java"), + "#!/bin/sh\necho test-java\n", + { mode: 0o755 }, + ); + const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], { + env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" }, + }); + expect(result.stdout.trim()).toBe("test-java"); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); + +it.effect("preserves shell metacharacters and newlines in remote arguments", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; + const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); + expect(result.stdout).toBe(value); + }); + }), +); + +describe("remote helper lifecycle", () => { + it.effect("reuses its own healthy helpers and stops only its own runtime", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); + const bin = NodePath.join(home, "bin"); + await NodeFSP.mkdir(bin); + await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const root = NodePath.join(home, ".t3/device"); + const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); + const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); + const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); + const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs"); + await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), { + recursive: true, + }); + await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION); + await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); + await NodeFSP.writeFile( + hub, + `import http from 'node:http'; import fs from 'node:fs'; +if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} +const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, + ); + await NodeFSP.writeFile( + agent, + `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process'; +const args=process.argv.slice(2); +const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1]; +const file=path.join(state,'daemon.json'); +if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } +else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } +else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +`, + ); + const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; + const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; + let invocation = 0; + const invoke = async ( + owner: string, + mode: "start" | "agent-start" | "stop-agent" | "stop", + upgraded = false, + ) => { + const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); + await NodeFSP.writeFile( + file, + `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + + remoteDeviceScript(owner, mode) + .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) + .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), + ); + const result = await exec(process.execPath, [file], { + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + }); + return result.stdout ? JSON.parse(result.stdout) : null; + }; + const template = NodePath.join(home, "hub-template"); + await NodeFSP.cp(hubDir, template, { recursive: true }); + await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); + const installLock = hubDir + ".lock"; + await NodeFSP.symlink("2147483647:exited-installer", installLock); + await NodeFSP.writeFile( + NodePath.join(bin, "npm"), + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + { mode: 0o755 }, + ); + await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + try { + const [manual, concurrent] = await Promise.all([ + invoke("one", "start"), + invoke("one", "start"), + ]); + expect(concurrent.hubPort).toBe(manual.hubPort); + expect(manual.daemonPort).toBeUndefined(); + await expect( + NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), + ).rejects.toThrow(); + const [first, concurrentAgent] = await Promise.all([ + invoke("one", "agent-start"), + invoke("one", "agent-start"), + ]); + expect(concurrentAgent.hubPort).toBe(first.hubPort); + expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const second = await invoke("two", "agent-start"); + const reused = await invoke("one", "agent-start"); + expect(reused.hubPort).toBe(first.hubPort); + expect(reused.daemonPort).toBe(first.daemonPort); + expect(second.hubPort).not.toBe(first.hubPort); + expect(second.daemonPort).not.toBe(first.daemonPort); + const firstHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), + ); + const secondHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), + ); + await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); + let repaired = await invoke("one", "agent-start"); + expect(repaired.hubPort).not.toBe(first.hubPort); + const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")) + .trim() + .split("\n"); + expect(stopped).toContain(String(firstHub.pid)); + expect(stopped).not.toContain(String(secondHub.pid)); + const previousDaemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + for (const [source, name, version] of [ + [hubDir, "expo-device-hub", nextHubVersion], + [agentDir, "agent-device", nextAgentVersion], + ]) { + const destination = NodePath.join(root, `tools/${name}@${version}`); + await NodeFSP.cp(source!, destination, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!); + } + const upgraded = await invoke("one", "agent-start", true); + expect(upgraded.entryPath).toContain(nextAgentVersion); + const upgradedHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), + ); + expect(upgradedHub.entryPath).toContain(nextHubVersion); + const upgradedDaemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid); + expect(await invoke("one", "agent-start", true)).toEqual(upgraded); + await NodeFSP.writeFile( + NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`), + "", + ); + repaired = await invoke("one", "agent-start", true); + expect( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), + ).toBe(String(upgradedDaemon.pid)); + expect(repaired.daemonPort).not.toBe(upgraded.daemonPort); + // Stop still uses the recorded entry when a future pinned package is not installed yet. + const originalScript = remoteDeviceScript("one", "stop-agent"); + const upgradedStop = NodePath.join(home, "upgraded-stop.cjs"); + await NodeFSP.writeFile( + upgradedStop, + originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), + ); + await exec(process.execPath, [upgradedStop], { + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + }); + const daemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + expect( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), + ).toBe(String(daemon.pid)); + expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true); + await invoke("one", "stop"); + expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); + expect( + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8")) + .owner, + ).toBe("two"); + } finally { + await invoke("one", "stop").catch(() => {}); + await invoke("two", "stop").catch(() => {}); + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbdb828c1a74588bcb10a84264232198cfa03920 --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.ts @@ -0,0 +1,192 @@ +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; + +/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */ +export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +if [ -z "$ANDROID_HOME" ]; then + if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk"; + elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi +fi +if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi +if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then + for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do + if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi + done +fi +if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi +`; + +/** Node runs this on the host. All paths it returns belong to that host. */ +export const remoteDeviceScript = ( + owner: string, + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", +) => + ` +const owner = ${JSON.stringify(owner)}; +const mode = ${JSON.stringify(mode)}; +const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; +const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; +` + + String.raw` +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const net = require('node:net'); +const { spawn, spawnSync } = require('node:child_process'); +const root = path.join(os.homedir(), '.t3', 'device'); +const state = path.join(root, 'hosts', owner); +const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); +const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; +const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const stopHub = hub => { + if (!hub || hub.owner !== owner) return; + const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; + if (command.includes(hub.entryPath) && command.includes(String(hub.port))) { + try { process.kill(hub.pid, 'SIGTERM'); } catch {} + } +}; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } }; +const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); }); +async function acquireLock(lock, complete = () => false) { + const deadline = Date.now() + 600000; + const token = process.pid + ':' + require('node:crypto').randomUUID(); + const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; + while (true) { + try { + // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner. + fs.symlinkSync(token, lock); + return () => { if (owner() === token) fs.unlinkSync(lock); }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + if (complete()) return null; + const previous = owner(); + if (previous === null) continue; + const pid = Number(previous.split(':')[0]); + if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock); + try { process.kill(pid, 0); } catch (error) { + if (error.code === 'ESRCH' && owner() === previous) { + try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + continue; + } + } + if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.'); + await sleep(500); + } + } +} +async function install(name, version, entry) { + const dir = path.join(root, 'tools', name + '@' + version); + const file = path.join(dir, 'node_modules', name, entry); + const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version; + if (complete()) return file; + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const lock = dir + '.lock'; + const release = await acquireLock(lock, complete); + if (!release) return file; + let staging; + try { + if (complete()) return file; + staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-')); + const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); + if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000))); + if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name); + fs.writeFileSync(path.join(staging, '.install-complete'), version); + fs.rmSync(dir, { recursive: true, force: true }); + fs.renameSync(staging, dir); + return file; + } finally { + if (staging) fs.rmSync(staging, { recursive: true, force: true }); + release(); + } +} +(async () => { + const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0; + const android = run('adb', ['version']).status === 0; + const platforms = [ + { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) }, + { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, + ]; + if (mode === 'probe') { + if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); + if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); + console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + } + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); + // Serialize starts and stops for this environment/host owner, including agent startup. + const hostLock = path.join(state, 'runtime.lock'); + const releaseHost = await acquireLock(hostLock); + try { + const hubFile = path.join(state, 'hub.json'); + const daemonFile = path.join(state, 'daemon.json'); + const agentFile = path.join(state, 'agent.json'); + if (mode === 'stop' || mode === 'stop-agent') { + const hub = read(hubFile); + if (mode === 'stop' && hub && hub.owner === owner) { + stopHub(hub); + fs.rmSync(hubFile, { force: true }); + } + const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); + if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); + return; + } + if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); + const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); + let hub = read(hubFile); + if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) { + stopHub(hub); + for (let attempt = 0; attempt < 5; attempt++) { + const hubPort = await port(); + const log = fs.openSync(path.join(state, 'hub.log'), 'a'); + const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], { + cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + }); + try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); } + finally { fs.closeSync(log); } + child.unref(); + hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; + write(hubFile, hub); + const deadline = Date.now() + 30000; + let listening = false; + while (child.exitCode === null && child.signalCode === null) { + if (await healthy(hub.port, '/readyz')) { listening = true; break; } + if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); } + await sleep(200); + } + if (listening) break; + // Port reservation and binding happen in different processes. Retry an early exit with a fresh port. + fs.rmSync(hubFile, { force: true }); + if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log')); + } + } + let agentResult = {}; + if (mode === 'agent-start') { + const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); + const previousAgent = read(agentFile)?.entryPath; + let daemon = read(daemonFile); + if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) { + const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]); + if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.'); + fs.rmSync(daemonFile, { force: true }); + daemon = null; + } + if (!daemon) { + fs.rmSync(daemonFile, { force: true }); + const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' }; + delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; + run(process.execPath, [agentEntry, 'devices', '--json'], { env }); + daemon = read(daemonFile); + } + if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state); + write(agentFile, { entryPath: agentEntry }); + agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry }; + } + const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); + const optional = file => fs.existsSync(file) ? file : null; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); + } finally { releaseHost(); } +})().catch(error => { console.error(error.message); process.exitCode = 1; }); +`; diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 24097a92faacf814b352dbf3ec168fe7f8d6da1b..3a9307314c393fe06b9885366abf790b4d2973ed 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, agentCli: Effect.succeed("/cli"), + testHost: () => Effect.die("not used"), agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]), }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 3bc89d22bc600ed0e3efa224e64fe9a768d9b170..feb69ec630d61c748e97885e6dc4612e0b329aa9 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -134,6 +134,9 @@ const handlers = { .filter((session) => session.threadId === scope.threadId) .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); return { + hostStatuses: Object.fromEntries( + Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId), + ), hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts, devices: hostId ? state.devices.filter((device) => device.hostId === hostId) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 71a8ed19d9053361d4eed590c04699281eb08aa6..f59a753d9c72564dda8f627f7b1282ede20235be 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2770,6 +2770,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), { "rpc.aggregate": "device", }), + [WS_METHODS.deviceTestHost]: (input) => + observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { + "rpc.aggregate": "device", + }), [WS_METHODS.deviceList]: (_input) => observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { "rpc.aggregate": "device", diff --git a/apps/web/src/components/device/DeviceHostAvailability.tsx b/apps/web/src/components/device/DeviceHostAvailability.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03a0b175dae8f9698165ed6038ff49b84bba85af --- /dev/null +++ b/apps/web/src/components/device/DeviceHostAvailability.tsx @@ -0,0 +1,27 @@ +import type { DevicePlatformAvailability } from "@t3tools/contracts"; +import { Check, Minus } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; + +export function DeviceHostAvailability({ + platforms, +}: { + platforms: ReadonlyArray; +}) { + return ( +
+ {platforms.map((platform) => ( + + }> + {platform.available ? : } + {platform.platform === "ios" ? "iOS" : "Android"}{" "} + {platform.available ? "available" : "unavailable"} + + + {platform.reason ?? + (platform.platform === "ios" ? "iOS available" : "Android available")} + + + ))} +
+ ); +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e61cf742dfd58321198580def401a6a9c6fa9d41 --- /dev/null +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -0,0 +1,334 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { AppleIcon, AndroidIcon } from "../Icons"; +import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; +import { Spinner } from "../ui/spinner"; +import type { + DevicePlatformAvailability, + EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { randomUUID } from "../../lib/utils"; +import { useState } from "react"; +import { deviceEnvironment, useDeviceState } from "../../state/device"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { MoreVertical, PlusIcon } from "lucide-react"; +import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; +import { SettingsRow } from "./settingsLayout"; + +/** Host names and identity paths belong to the selected environment, never all environments. */ +export function DeviceHostsSettings(props: { + environmentId: EnvironmentId | null; + hosts: ReadonlyArray; +}) { + const update = useAtomCommand(serverEnvironment.updateSettings); + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const { state } = useDeviceState(props.environmentId); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const validPort = (port: number | undefined) => + port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); + const [checks, setChecks] = useState< + Record< + string, + { pending?: boolean; platforms?: ReadonlyArray; error?: string } + > + >({}); + const setCheck = (id: string, value: (typeof checks)[string]) => + setChecks((current) => ({ ...current, [id]: value })); + const save = async (hosts: ReadonlyArray) => { + if (!props.environmentId) return; + setBusy(true); + try { + const saved = await update({ + environmentId: props.environmentId, + input: { patch: { deviceHosts: hosts } }, + }); + if (saved._tag === "Success") { + setEditing(null); + } + } finally { + setBusy(false); + } + }; + const testConnection = async (host: SshDeviceHostConfig) => { + if (!props.environmentId || checks[host.id]?.pending) return; + setCheck(host.id, { pending: true }); + try { + const summary = await test({ environmentId: props.environmentId, input: host }); + setCheck( + host.id, + summary._tag === "Failure" + ? { error: Cause.pretty(summary.cause) } + : { platforms: summary.value.platforms }, + ); + } catch (error) { + setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); + } + }; + return ( + { + setEditing({ id: randomUUID(), label: "", target: "" }); + }} + > + Add host + + } + > +
+ {!props.environmentId ? ( +

+ Select one connected environment to manage its device hosts. +

+ ) : ( + <> + {props.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + const check = checks[host.id]; + const platforms = + check?.platforms ?? + state.hosts.find((value) => value.id === host.id)?.platforms ?? + []; + const progress = check?.pending + ? "Checking connection…" + : status?.status === "installing" + ? "Installing device support…" + : status?.status === "starting" + ? "Connecting…" + : null; + const error = + check?.error ?? (status?.status === "failed" ? status.detail : undefined); + return ( +
+
+
+

{host.label}

+ {platforms + .filter((platform) => platform.available) + .map((platform) => ( + + + } + > + {platform.platform === "ios" ? ( + + ) : ( + + )} + + + {platform.platform === "ios" ? "iOS available" : "Android available"} + + + ))} +
+

{host.target}

+ {error ? ( +
+
+ Connection failed +

{error}

+
+
+ ) : null} +
+ {progress ? ( + + + {progress} + + ) : null} + + + } + > + + + + { + setEditing(host); + }} + > + Edit + + + void save(props.hosts.filter((value) => value.id !== host.id)) + } + > + Remove + + + + +
+ ); + })} + {editing ? ( +
{ + event.preventDefault(); + void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + }} + > + + + + +
+ + + +
+ {checks[editing.id]?.pending ? ( + + + Checking connection… + + ) : null} + {checks[editing.id]?.platforms ? ( + + ) : null} + {checks[editing.id]?.error ? ( +

+ {checks[editing.id]?.error} +

+ ) : null} + + ) : null} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3f76f49cd5f88f234d91f7f334cd7fe0a331973..8cb21532a42617260ed403b44d6f20d7a6151ffc 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceHostsSettings } from "./DeviceHostsSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, @@ -12,6 +13,7 @@ import { type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, + type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() { function DeviceIntegrationSettings() { const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + const { environments } = useEnvironments(); + const [selectedId, setSelectedId] = useState(null); + const selected = + environments.find((environment) => environment.environmentId === selectedId) ?? + environments.find( + (environment) => environment.environmentId === primaryEnvironment?.environmentId, + ) ?? + environments[0]; + const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; + const environmentId = connected ? selected.environmentId : null; + + return ( + + {environments.length > 1 ? ( + setSelectedId(value)} + > + + {selected?.label ?? "Select environment"} + + + {environments.map((environment) => ( + + {environment.label} + {environment.connection.phase === "connected" ? "" : " · Offline"} + + ))} + + + } + /> + ) : null} + + + ); +} + +function DeviceIntegrationControls({ + environmentId, + hosts, + enabled, + agentAccessEnabled, +}: { + environmentId: EnvironmentId | null; + hosts: ReadonlyArray; + enabled: boolean; + agentAccessEnabled: boolean; +}) { const { state, loaded } = useDeviceState(environmentId); const configure = useAtomCommand(deviceEnvironment.configure); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); - const enabled = state.hostStatus !== "disabled"; const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; const [platformsRevealed, setPlatformsRevealed] = useState(false); // Keep diagnostics visible through subsequent agent setup and refresh phases. if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { + if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { setPlatformsRevealed(true); } @@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { }; return ( - + <> {pending === "agent" ? : null} @@ -689,7 +748,8 @@ function DeviceIntegrationSettings() { {state.hostStatusDetail}

) : null} -
+ + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 24892b0e1e3bfbacf6eca82f0830c521db99485e..f56579990714185c7df940a68cfa562366621a3e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, + { + id: "device-hosts", + title: "Device hosts", + to: "/settings/integrations", + searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + }, { id: "agent-device-access", title: "Agent device access", diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 49027022fdbc8167fa661482400a28a66e5dfe14..9b34851439ca31a68699c421502e34ff22132865 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -3,8 +3,7 @@ The environment server owns simulators and emulators the way it owns terminals: discovery, streaming, and agent access all run there, and every client reaches them through the environment connection. This is what makes the -Device panel work over Tailscale and T3 Connect, and what will let a device -host on another machine slot in later. +Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices. ## Two external tools, one seam @@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down. Everything platform-specific sits behind [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the proxy, and the MCP tools only see a hub origin and an agent-device endpoint. -An SSH or cloud host would forward those two things to the server and change -nothing above it. +SSH hosts forward both endpoints to server loopback. Every proxied request +also carries the host id; device ids alone are not unique across hosts. ## The hub is never exposed @@ -57,9 +56,8 @@ screenshot capture and stream tuning. The `device_*` toolkit is deliberately four tools: list, open, screenshot, and close. Driving happens through the `agent-device` CLI, which has the semantic snapshot model agents need and stays current with its own releases. T3 prepends -a shim directory to the provider's PATH and sets -`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the -agent never handles the endpoint or token. +a shim directory to the provider's PATH. The CLI installs on the environment +server even when that server cannot run simulators. Hosts start on demand. That environment is fixed when the provider subprocess spawns, so [`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) diff --git a/docs/user/devices.md b/docs/user/devices.md index 51a0a578b607fbd68be925fbc624e055282a4ca8..7d1b91792ae303ba5da6b12f80e7c5c2f305b64e 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it. Choose a running device to watch it, or choose **Start** next to a stopped device to boot it. The panel shows when you or an agent starts a device. +Each device opens in its own tab. Use **+ → Device** to open another, and +double-click a tab name or choose **Rename** from its context menu to rename it. +Only the visible tab streams video; switching tabs keeps both devices running. Turn off the device hub in **Settings → Integrations → Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices. The screen is interactive: click and drag to touch, type while the screen is focused, and use the toolbar for Home, Back, and Recents on Android, rotate on iOS, and power off. Close the tab to stop watching; the device keeps running -unless you power it off. +unless you power it off. Closed tabs stay closed after a reload. To watch the +device again, choose it from **+ → Device**. ## Tools @@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the local network, Tailscale, and T3 Connect. Live video needs a secure page (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower still-image stream and Android cannot show video. + +## SSH device hosts + +In Settings → Integrations → Devices, select one connected environment +and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with +an optional identity file and port. These resolve on the environment server, +so use the SSH configuration and keys available there. Password prompts are +not supported. + +**Test connection** checks SSH, Node, npm, and platform tools without installing +anything. The first device listing installs pinned device tools on the host. +Node 22 or newer and npm must be available to non-interactive SSH commands. +T3 checks common Homebrew and Android SDK locations; custom installations need +the appropriate PATH and ANDROID_HOME on the host. + +The picker identifies devices by host when several hosts are configured. +Connections recover after interruptions. Removing a host closes its device +sessions and stops its T3 helpers when reachable; simulators keep running. + +T3 provides discovery, streaming, and control. Arrange app builds, +installation, and connectivity to development servers such as Metro separately. +A simulator on another machine cannot reach Metro through your environment's +localhost without forwarding or another reachable address. diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index df27f793720c242ed7f917814014b975dad23932..1e6523497ef38c9cba0428b25fc21828155b1a4e 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms( scheduler, concurrency, }), + testHost: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:test-host", + tag: WS_METHODS.deviceTestHost, + }), list: createEnvironmentRpcCommand(runtime, { label: "environment-data:device:list", tag: WS_METHODS.deviceList, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 895a954e2e408c6a4112967367d09862922fe70b..03102dd62b44ccbea534d7346f91086e14b2beda 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type; /** The server machine. Always present; other host kinds are future work. */ export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; +/** SSH aliases and key paths are resolved on the environment server. */ +export const SshDeviceHostConfig = Schema.Struct({ + id: DeviceHostId.check( + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), + Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."), + ), + label: TrimmedNonEmptyString, + target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)), + identityFile: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), +}); +export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type; + +export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( + Schema.makeFilter( + (hosts) => + new Set(hosts.map((host) => host.id)).size === hosts.length || + "Device host ids must be unique.", + ), +); + /** Simulator udid or adb serial (an AVD name while it is not running). */ export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); export type DeviceId = typeof DeviceId.Type; @@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, - kind: Schema.Literals(["local"]), + kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), hubInstalled: Schema.Boolean, @@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type; // panel describe devices the same way. export const DeviceToolListResult = Schema.Struct({ + hostStatuses: DeviceServiceState.fields.hostStatuses, hosts: Schema.Array(DeviceHostSummary), devices: Schema.Array(DeviceSummary), /** Devices already open in this thread's Device panel. */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a5d35bf2361dfeb74cbfddc6e774bd1d03e723b6..5e11aeb28fa367bad0cb5f14a7003e7ead885081 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -189,6 +189,8 @@ import { DeviceDetailInput, DeviceError, DeviceListInput, + SshDeviceHostConfig, + DeviceHostSummary, DeviceOpenInput, DeviceServiceState, DeviceSession, @@ -328,6 +330,7 @@ export const WS_METHODS = { // Device methods deviceConfigure: "device.configure", deviceList: "device.list", + deviceTestHost: "device.testHost", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov stream: true, }); +const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, { + payload: SshDeviceHostConfig, + success: DeviceHostSummary, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeDiscoveredLocalServersRpc, WsDeviceConfigureRpc, WsDeviceListRpc, + WsDeviceTestHostRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 8cbf0e55b9df1c396a2fcf373d06712ea5fd6622..7322307c1f89f0b8ec937ac53731bc1c3c78abaf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => { expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); }); }); + +const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings); + +it("validates remote device hosts and rejects ambiguous host ids", () => { + const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 }; + expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]); + expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow(); + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow(); + expect(() => + decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }), + ).toThrow(); + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 96516367612c7ce35fe8a341214b0eacd3c9f95a..dd6136461fc168f77cc6084db7bcd94ad7bcae5b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,3 +1,4 @@ +import { SshDeviceHostConfigs } from "./device.ts"; import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; @@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({ enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Whether the server-local Device panel setup flow has been completed. */ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), + deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a5e428fcdaacb871176dc8974137f20a2ce7bf1c..cc783fe64bf33dc099ab9d491e36834308307192 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -21,6 +21,16 @@ import { } from "./serverSettings.ts"; describe("serverSettings helpers", () => { + it("replaces SSH host lists when saving, editing, and removing hosts", () => { + const host = { id: "mini", label: "Mac mini", target: "mini" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); + expect(saved.deviceHosts).toEqual([host]); + const replacement = { ...host, target: "other-mini" }; + const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] }); + expect(edited.deviceHosts).toEqual([replacement]); + expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c58c455b4655a508e1ad685cc92e3f61b531b35..bebd36cec2e8973cd203c02e87adf1a04cc4dda4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/ssh': + specifier: workspace:* + version: link:../../packages/ssh '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale -- 2.54.0