From 52e0af3217efbaac682a33286ed3d651cef02a24 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:13:27 -0700 Subject: [PATCH 01/15] feat(devices): connect simulator hosts over SSH --- 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 +- apps/server/src/device/DeviceService.ts | 215 +++++++++--- apps/server/src/device/LocalDeviceHost.ts | 1 + apps/server/src/device/SshDeviceHost.ts | 321 ++++++++++++++++++ .../server/src/device/sshDeviceScript.test.ts | 84 +++++ apps/server/src/device/sshDeviceScript.ts | 131 +++++++ apps/server/src/mcp/McpDeviceToolkit.test.ts | 1 + .../src/mcp/toolkits/device/handlers.ts | 3 + apps/server/src/ws.ts | 4 + .../settings/DeviceHostsSettings.tsx | 235 +++++++++++++ .../settings/ProjectDefaultsSettings.tsx | 6 + .../src/components/settings/settingsSearch.ts | 6 + docs/internals/devices.md | 12 +- docs/user/devices.md | 23 ++ 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 + pnpm-lock.yaml | 3 + 24 files changed, 1053 insertions(+), 54 deletions(-) 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/settings/DeviceHostsSettings.tsx diff --git a/apps/server/package.json b/apps/server/package.json index 7d27d39d2eb3..84a8c6a5988b 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 97afa4f40775..d7d1be455fa1 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 ed6b5a87335d..c22dcfe86610 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 12781b31b4b5..509dac526b93 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 8d1bafbc5dfc..34ff2769362e 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/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 866663dbf21c..07ad5a9f58a9 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,14 @@ const vendorPrefix = (platform: DevicePlatform) => export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, + testHost: DeviceService["Service"]["testHost"] = () => + Effect.fail( + new DeviceOperationError({ + operation: "test host", + reason: "request_failed", + cause: new Error("SSH unavailable"), + }), + ), configureAgent: ( hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady, @@ -249,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (state.hostStatuses[host.id]?.status !== "ready") { yield* setHostStatus(host.id, { status: "ready" }); } + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); return { hostId: host.id, ...ready }; }, lifecycleLock.withPermit, @@ -260,7 +280,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); }); @@ -364,11 +385,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 || hosts.get(ready.hostId) !== host) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, @@ -578,6 +600,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 +785,62 @@ 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 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, + }), + setHostStatus, + refreshHosts: Effect.gen(function* () { + const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); + yield* publish((state) => ({ + ...state, + hosts: summaries, + hostStatuses: Object.fromEntries( + Object.entries(state.hostStatuses).filter(([id]) => hosts.has(id)), + ), + devices: state.devices.filter((device) => hosts.has(device.hostId)), + sessions: state.sessions.filter((session) => hosts.has(session.hostId)), + })); + }), + }; }); /** @public Service construction is part of the canonical Effect module API. */ @@ -807,7 +850,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 +870,86 @@ 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 DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + ), + ), + configureAgent, + ); + const hostContext = + yield* Effect.context>>(); + const configured = new Map(); + const lock = yield* Semaphore.make(1); + const reconcile = (next: ReadonlyArray) => + lock.withPermit( + Effect.gen(function* () { + 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); + yield* Scope.close(previous.scope, Exit.void); + yield* fs + .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true }) + .pipe(Effect.ignore); + } + for (const host of next) { + if (configured.has(host.id)) continue; + const hostScope = yield* Scope.make(); + 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/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b24ebfbd98b6..5a18d6e826a6 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.ts b/apps/server/src/device/SshDeviceHost.ts new file mode 100644 index 000000000000..9ab96f0c4588 --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.ts @@ -0,0 +1,321 @@ +import * as NodeCrypto from "node:crypto"; +import { + type DeviceHostSummary, + DevicePlatformAvailability, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; +import { 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 { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ServerConfig } from "../config.ts"; +import { DeviceHostError, type DeviceHost, type DeviceHostReady } 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.Int, + token: Schema.String, + entryPath: 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 failure = (config: SshDeviceHostConfig, step: string) => (cause: unknown) => + new DeviceHostError({ + hostId: config.id, + step, + detail: cause instanceof Error ? cause.message : String(cause), + }); +const bootstrap = (config: SshDeviceHostConfig, owner: string, mode: "probe" | "start" | "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" ? 1_300_000 : 45_000, + }).pipe(Effect.mapError(failure(config, mode))); + +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(failure(config, "reading probe result")), + ); + return { + id: config.id, + label: config.label, + kind: "ssh", + platforms: value.platforms, + } satisfies DeviceHostSummary; +}); + +export const make = Effect.fn("SshDeviceHost.make")(function* ( + config: SshDeviceHostConfig, + onReady: (ready: DeviceHostReady) => 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; + const net = yield* 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 ready: DeviceHostReady | null = null; + let connectionScope: Scope.Closeable | null = null; + let summary: DeviceHostSummary = { + id: config.id, + label: config.label, + kind: "ssh", + platforms: [], + }; + + const run: 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 connect = Effect.fn("SshDeviceHost.connect")(function* (): Effect.fn.Return< + DeviceHostReady, + DeviceHostError + > { + activated = true; + const result = yield* provide(bootstrap(config, owner, "start")); + yield* onStatus("starting"); + const remote = yield* decodeStarted(result.stdout.trim()).pipe( + Effect.mapError(failure(config, "reading host endpoints")), + ); + summary = { ...summary, platforms: remote.platforms }; + const hubPort = yield* net + .reserveLoopbackPort("127.0.0.1") + .pipe(Effect.mapError(failure(config, "reserving hub port"))); + const daemonPort = yield* net + .reserveLoopbackPort("127.0.0.1") + .pipe(Effect.mapError(failure(config, "reserving daemon port"))); + 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}`, + "-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(failure(config, "forwarding ports")), + ); + let stderr = ""; + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.sync(() => { + stderr = (stderr + chunk).slice(-2000); + }), + ), + Effect.forkIn(scope), + ); + const next: DeviceHostReady = { + nodePath: remote.nodePath, + hub: { origin: `http://127.0.0.1:${hubPort}` }, + 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.baseUrl, "/health"], + ]) { + yield* waitForHttpReady({ + baseUrl: baseUrl!, + path: route!, + timeoutMs: 15000, + makeError: () => + failure( + config, + "waiting for SSH forward", + )(stderr || "Forwarded endpoint did not answer."), + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + } + yield* onReady(next); + 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 = yield* http.get(`${next.agentDevice.baseUrl}/health`).pipe( + Effect.timeout("5 seconds"), + Effect.map((r) => r.status === 200), + Effect.orElseSucceed(() => false), + ); + 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 ensureReady: DeviceHost["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; + }), + ); + yield* Effect.addFinalizer(() => stop); + return { + id: config.id, + summary: Effect.sync(() => summary), + current: Effect.sync(() => ready), + ensureReady, + stop, + platformAvailability: (platform) => + provide(probe(config)).pipe( + Effect.map((value) => { + summary = value; + 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; +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts new file mode 100644 index 000000000000..aa2e52592e57 --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - verifies generated remote scripts using real shell and Node processes. +import { describe, expect, it } from "vite-plus/test"; +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, remoteDeviceScript } from "./sshDeviceScript.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +it.skipIf(NodeOS.platform() === "win32")( + "preserves shell metacharacters and newlines in remote arguments", + 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.skipIf(NodeOS.platform() === "win32")("remote helper lifecycle", () => { + it("reuses its own healthy helpers and stops only its own runtime", 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'; +const args=process.argv.slice(2); http.createServer((req,res)=>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')); try {process.kill(data.pid,'SIGTERM')} catch {} } +else if(args[0]==='serve') { const server=http.createServer((req,res)=>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 invoke = async (owner: string, mode: "start" | "stop") => { + const file = NodePath.join(home, `${owner}-${mode}.cjs`); + await NodeFSP.writeFile(file, remoteDeviceScript(owner, mode)); + 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; + }; + try { + const first = await invoke("one", "start"); + const second = await invoke("two", "start"); + const reused = await invoke("one", "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); + 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 000000000000..40af9ec342fe --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.ts @@ -0,0 +1,131 @@ +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 +`; + +/** Node runs this on the host. All paths it returns belong to that host. */ +export const remoteDeviceScript = (owner: string, mode: "probe" | "start" | "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 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 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 deadline = Date.now() + 600000; + while (true) { + try { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), String(process.pid)); break; } catch (error) { + if (error.code !== 'EEXIST') throw error; + if (complete()) return file; + try { const pid = Number(fs.readFileSync(path.join(lock, 'pid'), 'utf8')); if (pid > 0) process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') { fs.rmSync(lock, { recursive: true, force: true }); continue; } } + if (Date.now() > deadline) throw Error('Tool installation is locked at ' + lock + '. Check the other installer before removing the lock.'); + await sleep(500); + } + } + 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 }); + fs.rmSync(lock, { recursive: true, force: true }); + } +} +(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; + } + const hubFile = path.join(state, 'hub.json'); + const daemonFile = path.join(state, 'daemon.json'); + if (mode === 'stop') { + const hub = read(hubFile); + if (hub && hub.owner === owner) { + 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 {} + } + fs.rmSync(hubFile, { force: true }); + } + const entry = 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'); + const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); + let hub = read(hubFile); + if (!hub || hub.owner !== owner || !await healthy(hub.port, '/readyz')) { + 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' }, + }); + await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); + child.unref(); fs.closeSync(log); + hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; + write(hubFile, hub); + } + const deadline = Date.now() + 30000; + while (!await healthy(hub.port, '/readyz')) { + if (Date.now() > deadline) throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); + await sleep(200); + } + let daemon = read(daemonFile); + if (!daemon || !await healthy(daemon.httpPort, '/health')) { + 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); + 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, daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry, + helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); +})().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 24097a92faac..3a9307314c39 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 3bc89d22bc60..feb69ec630d6 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 71a8ed19d905..f59a753d9c72 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/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx new file mode 100644 index 000000000000..98486a2aeef0 --- /dev/null +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -0,0 +1,235 @@ +import type { 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 { SettingsSection } 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 [result, setResult] = useState(null); + 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); + setResult(null); + } + } finally { + setBusy(false); + } + }; + const testConnection = async (host: SshDeviceHostConfig) => { + if (!props.environmentId) return; + setBusy(true); + setResult(null); + try { + const summary = await test({ environmentId: props.environmentId, input: host }); + if (summary._tag === "Failure") { + setResult(Cause.pretty(summary.cause)); + return; + } + setResult( + summary.value.platforms + .map((platform) => + platform.available + ? `${platform.platform === "ios" ? "iOS" : "Android"} available` + : platform.reason, + ) + .join(". "), + ); + } catch (error) { + setResult(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }; + return ( + +

+ Connect simulators on other machines over SSH. Keys, aliases, and paths are read on the + selected environment. Device tools install there on first use. +

+ {!props.environmentId ? ( +

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

+ ) : ( + <> + {props.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + return ( +
+
+

{host.label}

+

+ {host.target} + {status ? ` · ${status.status}` : ""} +

+ {status?.detail ? ( +

{status.detail}

+ ) : null} +
+ + + +
+ ); + })} + {editing ? ( +
{ + event.preventDefault(); + void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + }} + > + + + + +
+ + + +
+
+ ) : ( + + )} + {result ? ( +

+ {result} +

+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 938000e01002..01458b85ccfc 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -30,6 +30,7 @@ import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { DeviceHostsSettings } from "./DeviceHostsSettings"; import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; import { searchableSetting } from "./settingsSearch"; @@ -389,6 +390,11 @@ export function ProjectDefaultsSettings({ } /> + ( 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 895a954e2e40..03102dd62b44 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 a5d35bf2361d..5e11aeb28fa3 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 8cbf0e55b9df..7322307c1f89 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 96516367612c..dd6136461fc1 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/pnpm-lock.yaml b/pnpm-lock.yaml index 6c58c455b465..bebd36cec2e8 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 From b0c470cbcfe5d98e6bad097aa81c7cccc0b1262b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:14:09 -0700 Subject: [PATCH 02/15] test(devices): use injected platform for remote script checks --- .../server/src/device/sshDeviceScript.test.ts | 132 ++++++++++-------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index aa2e52592e57..26eefb218ee7 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetch:off - verifies generated remote scripts using real shell and Node processes. -import { describe, expect, it } from "vite-plus/test"; +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"; @@ -10,42 +12,47 @@ import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); -it.skipIf(NodeOS.platform() === "win32")( - "preserves shell metacharacters and newlines in remote arguments", - async () => { - const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; - const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); - expect(result.stdout).toBe(value); - }, +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.skipIf(NodeOS.platform() === "win32")("remote helper lifecycle", () => { - it("reuses its own healthy helpers and stops only its own runtime", 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'; +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'; const args=process.argv.slice(2); http.createServer((req,res)=>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'; + ); + 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'); @@ -53,32 +60,35 @@ if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); tr else if(args[0]==='serve') { const server=http.createServer((req,res)=>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 invoke = async (owner: string, mode: "start" | "stop") => { - const file = NodePath.join(home, `${owner}-${mode}.cjs`); - await NodeFSP.writeFile(file, remoteDeviceScript(owner, mode)); - const result = await exec(process.execPath, [file], { - env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + ); + const invoke = async (owner: string, mode: "start" | "stop") => { + const file = NodePath.join(home, `${owner}-${mode}.cjs`); + await NodeFSP.writeFile(file, remoteDeviceScript(owner, mode)); + 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; + }; + try { + const first = await invoke("one", "start"); + const second = await invoke("two", "start"); + const reused = await invoke("one", "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); + 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 }); + } }); - return result.stdout ? JSON.parse(result.stdout) : null; - }; - try { - const first = await invoke("one", "start"); - const second = await invoke("two", "start"); - const reused = await invoke("one", "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); - 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 }); - } - }); + }), + ); }); From 3daf7c0add220381296e6682498eed69180c7b96 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:26:10 -0700 Subject: [PATCH 03/15] fix(devices): polish SSH settings after browser verification --- .../server/src/device/sshDeviceScript.test.ts | 2 +- .../settings/DeviceHostsSettings.tsx | 328 +++++++++--------- .../settings/ProjectDefaultsSettings.tsx | 2 +- 3 files changed, 167 insertions(+), 165 deletions(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 26eefb218ee7..4d3622f2fa1b 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - verifies generated remote scripts using real shell and Node processes. +// @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"; diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 98486a2aeef0..ce5405b499eb 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -63,173 +63,175 @@ export function DeviceHostsSettings(props: { }; return ( -

- Connect simulators on other machines over SSH. Keys, aliases, and paths are read on the - selected environment. Device tools install there on first use. -

- {!props.environmentId ? ( +

- Select one connected environment to manage its device hosts. + Connect simulators on other machines over SSH. Keys, aliases, and paths are read on the + selected environment. Device tools install there on first use.

- ) : ( - <> - {props.hosts.map((host) => { - const status = state.hostStatuses[host.id]; - return ( -
+ Select one connected environment to manage its device hosts. +

+ ) : ( + <> + {props.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + return ( +
+
+

{host.label}

+

+ {host.target} + {status ? ` · ${status.status}` : ""} +

+ {status?.detail ? ( +

{status.detail}

+ ) : null} +
+ + + +
+ ); + })} + {editing ? ( +
{ + event.preventDefault(); + void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + }} > -
-

{host.label}

-

- {host.target} - {status ? ` · ${status.status}` : ""} -

- {status?.detail ? ( -

{status.detail}

- ) : null} + + + + +
+ + +
- - - -
- ); - })} - {editing ? ( - { - event.preventDefault(); - void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); - }} - > - - - - -
- - - -
-
- ) : ( - - )} - {result ? ( -

- {result} -

- ) : null} - - )} + + ) : ( + + )} + {result ? ( +

+ {result} +

+ ) : null} + + )} +
); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 01458b85ccfc..3e7fd536953a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -393,7 +393,7 @@ export function ProjectDefaultsSettings({ Date: Tue, 8 Sep 2026 16:34:56 -0700 Subject: [PATCH 04/15] fix(devices): discard sessions when an SSH host changes --- apps/server/src/device/DeviceService.test.ts | 1 - apps/server/src/device/DeviceService.ts | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index b45db236fab6..36c01b6f3c2f 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -21,7 +21,6 @@ import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService. const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", - hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 07ad5a9f58a9..dfe251b94d7c 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -198,6 +198,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, @@ -830,15 +831,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* setHostStatus, 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]) => hosts.has(id)), + Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)), ), - devices: state.devices.filter((device) => hosts.has(device.hostId)), - sessions: state.sessions.filter((session) => hosts.has(session.hostId)), + devices: state.devices.filter((device) => unchanged(device.hostId)), + sessions: state.sessions.filter((session) => unchanged(session.hostId)), })); + publishedHosts = new Map(hosts); }), }; }); From e49ae7c48a5329dbd0124846ada379c99db0d181 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:39:12 -0700 Subject: [PATCH 05/15] fix(devices): stop unhealthy remote hubs before replacement --- apps/server/src/device/DeviceToolchain.ts | 4 ++-- .../server/src/device/sshDeviceScript.test.ts | 24 ++++++++++++++++--- apps/server/src/device/sshDeviceScript.ts | 13 ++++++---- packages/shared/src/serverSettings.test.ts | 10 ++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index e6b43cd81ee8..fa8d8cd11d17 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/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 4d3622f2fa1b..fc6ab127a20b 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -47,8 +47,8 @@ describe("remote helper lifecycle", () => { await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); await NodeFSP.writeFile( hub, - `import http from 'node:http'; -const args=process.argv.slice(2); http.createServer((req,res)=>res.end('ok')).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, + `import http from 'node:http'; import fs from 'node:fs'; +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, @@ -63,7 +63,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ); const invoke = async (owner: string, mode: "start" | "stop") => { const file = NodePath.join(home, `${owner}-${mode}.cjs`); - await NodeFSP.writeFile(file, remoteDeviceScript(owner, mode)); + 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), + ); const result = await exec(process.execPath, [file], { env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, }); @@ -77,6 +81,20 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr 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}`), ""); + const repaired = await invoke("one", "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)); await invoke("one", "stop"); expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); expect( diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 40af9ec342fe..c74ccc373770 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -30,6 +30,13 @@ 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)); }); }); @@ -83,10 +90,7 @@ async function install(name, version, entry) { if (mode === 'stop') { const hub = read(hubFile); if (hub && hub.owner === owner) { - 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 {} - } + stopHub(hub); fs.rmSync(hubFile, { force: true }); } const entry = path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); @@ -99,6 +103,7 @@ async function install(name, version, entry) { const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); let hub = read(hubFile); if (!hub || hub.owner !== owner || !await healthy(hub.port, '/readyz')) { + stopHub(hub); 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'], { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a5e428fcdaac..cc783fe64bf3 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 = { From 28aa7d6216431ed9a51c16bcee6145585879ba1d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:52:26 -0700 Subject: [PATCH 06/15] fix(devices): preserve separate consent for SSH agent helpers --- .../server/src/device/DeviceMultiHost.test.ts | 15 ++- apps/server/src/device/DeviceService.test.ts | 2 + apps/server/src/device/SshDeviceHost.ts | 112 +++++++++++++----- .../server/src/device/sshDeviceScript.test.ts | 20 +++- apps/server/src/device/sshDeviceScript.ts | 17 ++- 5 files changed, 125 insertions(+), 41 deletions(-) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index d586dffa1567..3b0d847ae55f 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -10,6 +10,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 }), @@ -74,8 +75,18 @@ 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"); + hosts.set("b", host("b")); + yield* service.refreshHosts; + 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; + 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 36c01b6f3c2f..46e159dbfc33 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -21,6 +21,7 @@ import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService. const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, @@ -69,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/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 9ab96f0c4588..0782293611ea 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -18,7 +18,12 @@ import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../config.ts"; -import { DeviceHostError, type DeviceHost, type DeviceHostReady } from "./DeviceHost.ts"; +import { + DeviceHostError, + type DeviceHost, + type DeviceHostReady, + type DeviceHostAgentReady, +} from "./DeviceHost.ts"; import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; const Probe = Schema.Struct({ @@ -28,9 +33,9 @@ const Probe = Schema.Struct({ const Started = Schema.Struct({ ...Probe.fields, hubPort: Schema.Int, - daemonPort: Schema.Int, - token: Schema.String, - entryPath: Schema.String, + 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), @@ -55,16 +60,20 @@ const failure = (config: SshDeviceHostConfig, step: string) => (cause: unknown) new DeviceHostError({ hostId: config.id, step, - detail: cause instanceof Error ? cause.message : String(cause), + cause, }); -const bootstrap = (config: SshDeviceHostConfig, owner: string, mode: "probe" | "start" | "stop") => +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" ? 1_300_000 : 45_000, + timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, }).pipe(Effect.mapError(failure(config, mode))); export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { @@ -76,13 +85,16 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi 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: DeviceHostReady) => Effect.Effect = () => Effect.void, + onReady: (ready: DeviceHostAgentReady) => Effect.Effect = () => + Effect.void, onStatus: ( status: "starting" | "ready" | "failed", detail?: string, @@ -118,12 +130,16 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const lock = yield* Semaphore.make(1); let stopped = false; let activated = false; - let ready: DeviceHostReady | null = null; + let wantsAgent = false; + let ready: (DeviceHostReady & { agentDevice?: 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: [], }; @@ -147,16 +163,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ); const connect = Effect.fn("SshDeviceHost.connect")(function* (): Effect.fn.Return< - DeviceHostReady, + DeviceHostReady & { agentDevice?: DeviceHostAgentReady["agentDevice"] }, DeviceHostError > { activated = true; - const result = yield* provide(bootstrap(config, owner, "start")); + const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); yield* onStatus("starting"); const remote = yield* decodeStarted(result.stdout.trim()).pipe( Effect.mapError(failure(config, "reading host endpoints")), ); - summary = { ...summary, platforms: remote.platforms }; + summary = { + ...summary, + platforms: remote.platforms, + hubInstalled: true, + agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, + }; const hubPort = yield* net .reserveLoopbackPort("127.0.0.1") .pipe(Effect.mapError(failure(config, "reserving hub port"))); @@ -181,8 +202,9 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( "-N", "-L", `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`, - "-L", - `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`, + ...(remote.daemonPort === undefined + ? [] + : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]), config.target, ], { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, @@ -202,20 +224,26 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ), Effect.forkIn(scope), ); - const next: DeviceHostReady = { + const next = { nodePath: remote.nodePath, hub: { origin: `http://127.0.0.1:${hubPort}` }, - agentDevice: { - baseUrl: `http://127.0.0.1:${daemonPort}`, - token: remote.token, - entryPath: remote.entryPath, - }, + ...(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.baseUrl, "/health"], + ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []), ]) { yield* waitForHttpReady({ baseUrl: baseUrl!, @@ -228,7 +256,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( )(stderr || "Forwarded endpoint did not answer."), }).pipe(Effect.provideService(HttpClient.HttpClient, http)); } - yield* onReady(next); + 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. @@ -240,11 +268,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.map((r) => r.status === 200), Effect.orElseSucceed(() => false), ); - const daemonAlive = yield* http.get(`${next.agentDevice.baseUrl}/health`).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; } }); @@ -274,7 +304,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return next; }); - const ensureReady: DeviceHost["ensureReady"] = (onPhase) => + const ensureReady: DeviceHost["Service"]["ensureReady"] = (onPhase) => lock.withPermit( Effect.gen(function* () { stopped = false; @@ -296,14 +326,38 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( 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(); + }), + ); yield* Effect.addFinalizer(() => stop); return { id: config.id, summary: Effect.sync(() => summary), current: Effect.sync(() => ready), ensureReady, + ensureAgentReady: () => + changeAgent(true).pipe( + Effect.flatMap((value) => + value?.agentDevice + ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) + : Effect.fail(failure(config, "starting agent tools")("Daemon endpoint missing")), + ), + ), + stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, platformAvailability: (platform) => provide(probe(config)).pipe( @@ -317,5 +371,5 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( reason: "Cannot reach device host. Test its SSH connection in Settings.", })), ), - } satisfies DeviceHost; + } satisfies DeviceHost["Service"]; }); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index fc6ab127a20b..739e220f4a8f 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -61,7 +61,10 @@ else if(args[0]==='serve') { const server=http.createServer((req,res)=>res.end(' 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 invoke = async (owner: string, mode: "start" | "stop") => { + const invoke = async ( + owner: string, + mode: "start" | "agent-start" | "stop-agent" | "stop", + ) => { const file = NodePath.join(home, `${owner}-${mode}.cjs`); await NodeFSP.writeFile( file, @@ -74,9 +77,14 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr return result.stdout ? JSON.parse(result.stdout) : null; }; try { - const first = await invoke("one", "start"); - const second = await invoke("two", "start"); - const reused = await invoke("one", "start"); + const manual = await invoke("one", "start"); + expect(manual.daemonPort).toBeUndefined(); + await expect( + NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), + ).rejects.toThrow(); + const first = await invoke("one", "agent-start"); + 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); @@ -88,13 +96,15 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), ); await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); - const repaired = await invoke("one", "start"); + const 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)); + await invoke("one", "stop-agent"); + 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( diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index c74ccc373770..9a832a63a580 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -12,7 +12,10 @@ if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDRO `; /** Node runs this on the host. All paths it returns belong to that host. */ -export const remoteDeviceScript = (owner: string, mode: "probe" | "start" | "stop") => +export const remoteDeviceScript = ( + owner: string, + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", +) => ` const owner = ${JSON.stringify(owner)}; const mode = ${JSON.stringify(mode)}; @@ -87,9 +90,9 @@ async function install(name, version, entry) { } const hubFile = path.join(state, 'hub.json'); const daemonFile = path.join(state, 'daemon.json'); - if (mode === 'stop') { + if (mode === 'stop' || mode === 'stop-agent') { const hub = read(hubFile); - if (hub && hub.owner === owner) { + if (mode === 'stop' && hub && hub.owner === owner) { stopHub(hub); fs.rmSync(hubFile, { force: true }); } @@ -100,7 +103,6 @@ async function install(name, version, entry) { 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'); - const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); let hub = read(hubFile); if (!hub || hub.owner !== owner || !await healthy(hub.port, '/readyz')) { stopHub(hub); @@ -119,6 +121,9 @@ async function install(name, version, entry) { if (Date.now() > deadline) throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); await sleep(200); } + let agentResult = {}; + if (mode === 'agent-start') { + const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); let daemon = read(daemonFile); if (!daemon || !await healthy(daemon.httpPort, '/health')) { fs.rmSync(daemonFile, { force: true }); @@ -128,9 +133,11 @@ async function install(name, version, entry) { daemon = read(daemonFile); } if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state); + 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, daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry, + 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')) } })); })().catch(error => { console.error(error.message); process.exitCode = 1; }); `; From 5573d1977fe77107112187746d37ba761e4ed588 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:00:57 -0700 Subject: [PATCH 07/15] fix(devices): serialize host replacement and recover SSH startup failures --- .../server/src/device/DeviceMultiHost.test.ts | 36 ++++++++++++-- apps/server/src/device/DeviceService.ts | 24 ++++++--- apps/server/src/device/SshDeviceHost.ts | 5 +- .../server/src/device/sshDeviceScript.test.ts | 14 ++++++ apps/server/src/device/sshDeviceScript.ts | 49 +++++++++++++------ .../settings/DeviceHostsSettings.tsx | 16 +++++- 6 files changed, 113 insertions(+), 31 deletions(-) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 3b0d847ae55f..b687525c92cf 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"; @@ -60,9 +62,18 @@ 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)); const listed = yield* service.list; expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); expect(listed.hostStatuses.offline?.status).toBe("failed"); @@ -75,8 +86,23 @@ 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"); - hosts.set("b", host("b")); - yield* service.refreshHosts; + 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"]); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index dfe251b94d7c..3dfb898117d4 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -261,15 +261,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), ), ); - const { state } = yield* SynchronizedRef.get(stateRef); - if (state.hostStatuses[host.id]?.status !== "ready") { - yield* setHostStatus(host.id, { status: "ready" }); - } 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" }); + } return { hostId: host.id, ...ready }; }, lifecycleLock.withPermit, @@ -797,6 +797,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), agentTarget: (input) => Effect.gen(function* () { + const host = yield* resolveHost(input.hostId); const ready = yield* agentReadinessIfSupported(input.hostId); if (!ready) return yield* new DeviceHostUnavailableError({ @@ -804,7 +805,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* 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); + 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, @@ -829,6 +839,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* sessionsForThread, }), setHostStatus, + withLifecycleLock: lifecycleLock.withPermit, refreshHosts: Effect.gen(function* () { const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); const unchanged = (id: DeviceHostId) => @@ -891,9 +902,8 @@ export const make = Effect.gen(function* () { const hostContext = yield* Effect.context>>(); const configured = new Map(); - const lock = yield* Semaphore.make(1); const reconcile = (next: ReadonlyArray) => - lock.withPermit( + service.withLifecycleLock( Effect.gen(function* () { for (const [id, previous] of configured) { if ( diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 0782293611ea..5daf3859202f 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -349,8 +349,9 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary: Effect.sync(() => summary), current: Effect.sync(() => ready), ensureReady, - ensureAgentReady: () => - changeAgent(true).pipe( + ensureAgentReady: (onPhase) => + onPhase("installing").pipe( + Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 739e220f4a8f..96523772a998 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -48,6 +48,7 @@ describe("remote helper lifecycle", () => { 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( @@ -76,6 +77,19 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); 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.mkdir(installLock); + await NodeFSP.utimes(installLock, 1, 1); + 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 = await invoke("one", "start"); expect(manual.daemonPort).toBeUndefined(); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 9a832a63a580..152dfb807878 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -55,7 +55,17 @@ async function install(name, version, entry) { try { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), String(process.pid)); break; } catch (error) { if (error.code !== 'EEXIST') throw error; if (complete()) return file; - try { const pid = Number(fs.readFileSync(path.join(lock, 'pid'), 'utf8')); if (pid > 0) process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') { fs.rmSync(lock, { recursive: true, force: true }); continue; } } + try { + const pid = Number(fs.readFileSync(path.join(lock, 'pid'), 'utf8')); + if (!Number.isSafeInteger(pid) || pid <= 0) throw Object.assign(Error('Invalid installer PID'), { code: 'INVALID_PID' }); + process.kill(pid, 0); + } catch (error) { + // A new owner may be between mkdir and writing its PID. Reclaim incomplete locks only after a grace period. + const incomplete = error.code === 'ENOENT' || error.code === 'INVALID_PID'; + let stale = false; + try { stale = Date.now() - fs.statSync(lock).mtimeMs > 30000; } catch (error) { if (error.code === 'ENOENT') continue; throw error; } + if (error.code === 'ESRCH' || (incomplete && stale)) { fs.rmSync(lock, { recursive: true, force: true }); continue; } + } if (Date.now() > deadline) throw Error('Tool installation is locked at ' + lock + '. Check the other installer before removing the lock.'); await sleep(500); } @@ -106,20 +116,29 @@ async function install(name, version, entry) { let hub = read(hubFile); if (!hub || hub.owner !== owner || !await healthy(hub.port, '/readyz')) { stopHub(hub); - 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' }, - }); - await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); - child.unref(); fs.closeSync(log); - hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; - write(hubFile, hub); - } - const deadline = Date.now() + 30000; - while (!await healthy(hub.port, '/readyz')) { - if (Date.now() > deadline) throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); - await sleep(200); + 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') { diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index ce5405b499eb..f8c1428a463f 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -19,6 +19,8 @@ export function DeviceHostsSettings(props: { 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 [result, setResult] = useState(null); const save = async (hosts: ReadonlyArray) => { if (!props.environmentId) return; @@ -184,7 +186,12 @@ export function DeviceHostsSettings(props: { @@ -192,7 +199,12 @@ export function DeviceHostsSettings(props: { size="sm" type="button" variant="outline" - disabled={busy || !editing.target.trim()} + disabled={ + busy || + !editing.label.trim() || + !editing.target.trim() || + !validPort(editing.port) + } onClick={() => void testConnection(editing)} > Test connection From c2ef55d613c753257e9c291a9184ff47ebe5c08c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:02:11 -0700 Subject: [PATCH 08/15] refactor(devices): map SSH failures at their boundaries --- apps/server/src/device/SshDeviceHost.ts | 53 +++++++++++++++++-------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 5daf3859202f..a3ecba519c5e 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -56,12 +56,6 @@ const commandArgs = (script: string) => [ "-c", quoteRemoteArg(remoteDeviceEnvironment + script), ]; -const failure = (config: SshDeviceHostConfig, step: string) => (cause: unknown) => - new DeviceHostError({ - hostId: config.id, - step, - cause, - }); const bootstrap = ( config: SshDeviceHostConfig, owner: string, @@ -74,12 +68,16 @@ const bootstrap = ( ), stdin: remoteDeviceScript(owner, mode), timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, - }).pipe(Effect.mapError(failure(config, mode))); + }).pipe( + Effect.mapError((cause) => new 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(failure(config, "reading probe result")), + Effect.mapError( + (cause) => new DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), + ), ); return { id: config.id, @@ -170,7 +168,10 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); yield* onStatus("starting"); const remote = yield* decodeStarted(result.stdout.trim()).pipe( - Effect.mapError(failure(config, "reading host endpoints")), + Effect.mapError( + (cause) => + new DeviceHostError({ hostId: config.id, step: "reading host endpoints", cause }), + ), ); summary = { ...summary, @@ -180,10 +181,19 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( }; const hubPort = yield* net .reserveLoopbackPort("127.0.0.1") - .pipe(Effect.mapError(failure(config, "reserving hub port"))); + .pipe( + Effect.mapError( + (cause) => new DeviceHostError({ hostId: config.id, step: "reserving hub port", cause }), + ), + ); const daemonPort = yield* net .reserveLoopbackPort("127.0.0.1") - .pipe(Effect.mapError(failure(config, "reserving daemon port"))); + .pipe( + Effect.mapError( + (cause) => + new DeviceHostError({ hostId: config.id, step: "reserving daemon port", cause }), + ), + ); const scope = yield* Scope.make(); connectionScope = scope; const child = yield* spawner @@ -212,7 +222,9 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ) .pipe( Effect.provideService(Scope.Scope, scope), - Effect.mapError(failure(config, "forwarding ports")), + Effect.mapError( + (cause) => new DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), + ), ); let stderr = ""; yield* child.stderr.pipe( @@ -250,10 +262,11 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( path: route!, timeoutMs: 15000, makeError: () => - failure( - config, - "waiting for SSH forward", - )(stderr || "Forwarded endpoint did not answer."), + new 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 }); @@ -355,7 +368,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.flatMap((value) => value?.agentDevice ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) - : Effect.fail(failure(config, "starting agent tools")("Daemon endpoint missing")), + : Effect.fail( + new DeviceHostError({ + hostId: config.id, + step: "starting agent tools", + cause: new Error("Daemon endpoint missing"), + }), + ), ), ), stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), From add75c9d90c371f17d91e0295fa531cba17c4106 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:12:26 -0700 Subject: [PATCH 09/15] fix(devices): serialize remote helper startup and clean failed agent activation --- apps/server/src/device/SshDeviceHost.test.ts | 119 ++++++++++++++++++ apps/server/src/device/SshDeviceHost.ts | 111 +++++++++------- .../server/src/device/sshDeviceScript.test.ts | 16 ++- apps/server/src/device/sshDeviceScript.ts | 29 +++-- 4 files changed, 218 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/device/SshDeviceHost.test.ts diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts new file mode 100644 index 000000000000..877f71823c97 --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -0,0 +1,119 @@ +// @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 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 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) { + 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); + 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 index a3ecba519c5e..954f8a77065a 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -5,7 +5,7 @@ import { type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; -import { NetService } from "@t3tools/shared/Net"; +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"; @@ -15,15 +15,11 @@ 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 { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ServerConfig } from "../config.ts"; -import { - DeviceHostError, - type DeviceHost, - type DeviceHostReady, - type DeviceHostAgentReady, -} from "./DeviceHost.ts"; +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({ @@ -69,14 +65,17 @@ const bootstrap = ( stdin: remoteDeviceScript(owner, mode), timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, }).pipe( - Effect.mapError((cause) => new DeviceHostError({ hostId: config.id, step: mode, cause })), + 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 DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), ), ); return { @@ -91,8 +90,9 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi export const make = Effect.fn("SshDeviceHost.make")(function* ( config: SshDeviceHostConfig, - onReady: (ready: DeviceHostAgentReady) => Effect.Effect = () => - Effect.void, + onReady: ( + ready: DeviceHost.DeviceHostAgentReady, + ) => Effect.Effect = () => Effect.void, onStatus: ( status: "starting" | "ready" | "failed", detail?: string, @@ -100,8 +100,8 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const server = yield* ServerConfig; - const net = yield* NetService; + 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; @@ -129,8 +129,11 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( let stopped = false; let activated = false; let wantsAgent = false; - let ready: (DeviceHostReady & { agentDevice?: DeviceHostAgentReady["agentDevice"] }) | null = - null; + let ready: + | (DeviceHost.DeviceHostReady & { + agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"]; + }) + | null = null; let connectionScope: Scope.Closeable | null = null; let summary: DeviceHostSummary = { id: config.id, @@ -141,7 +144,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( platforms: [], }; - const run: DeviceHostReady["run"] = (command, args, options) => + const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => provide( runSshCommand(targetFor(config), { preHostArgs: identityArgs(config), @@ -161,8 +164,8 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ); const connect = Effect.fn("SshDeviceHost.connect")(function* (): Effect.fn.Return< - DeviceHostReady & { agentDevice?: DeviceHostAgentReady["agentDevice"] }, - DeviceHostError + DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, + DeviceHost.DeviceHostError > { activated = true; const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); @@ -170,7 +173,11 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const remote = yield* decodeStarted(result.stdout.trim()).pipe( Effect.mapError( (cause) => - new DeviceHostError({ hostId: config.id, step: "reading host endpoints", cause }), + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reading host endpoints", + cause, + }), ), ); summary = { @@ -179,21 +186,26 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; - const hubPort = yield* net - .reserveLoopbackPort("127.0.0.1") - .pipe( - Effect.mapError( - (cause) => new DeviceHostError({ hostId: config.id, step: "reserving hub port", cause }), - ), - ); - const daemonPort = yield* net - .reserveLoopbackPort("127.0.0.1") - .pipe( - Effect.mapError( - (cause) => - new DeviceHostError({ hostId: config.id, step: "reserving daemon port", cause }), - ), - ); + 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 @@ -223,7 +235,8 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( .pipe( Effect.provideService(Scope.Scope, scope), Effect.mapError( - (cause) => new DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), ), ); let stderr = ""; @@ -262,7 +275,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( path: route!, timeoutMs: 15000, makeError: () => - new DeviceHostError({ + new DeviceHost.DeviceHostError({ hostId: config.id, step: "waiting for SSH forward", cause: new Error(stderr || "Forwarded endpoint did not answer."), @@ -317,7 +330,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return next; }); - const ensureReady: DeviceHost["Service"]["ensureReady"] = (onPhase) => + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => lock.withPermit( Effect.gen(function* () { stopped = false; @@ -353,7 +366,17 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( connectionScope = null; if (previousScope) yield* Scope.close(previousScope, Exit.void); if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent")); - return yield* connect(); + 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); @@ -369,7 +392,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( value?.agentDevice ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) : Effect.fail( - new DeviceHostError({ + new DeviceHost.DeviceHostError({ hostId: config.id, step: "starting agent tools", cause: new Error("Daemon endpoint missing"), @@ -382,7 +405,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( platformAvailability: (platform) => provide(probe(config)).pipe( Effect.map((value) => { - summary = value; + summary = { ...summary, platforms: value.platforms }; return value.platforms.find((p) => p.platform === platform)!; }), Effect.orElseSucceed(() => ({ @@ -391,5 +414,5 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( reason: "Cannot reach device host. Test its SSH connection in Settings.", })), ), - } satisfies DeviceHost["Service"]; + } satisfies DeviceHost.DeviceHost["Service"]; }); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 96523772a998..914618379986 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -62,11 +62,12 @@ else if(args[0]==='serve') { const server=http.createServer((req,res)=>res.end(' 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(); } `, ); + let invocation = 0; const invoke = async ( owner: string, mode: "start" | "agent-start" | "stop-agent" | "stop", ) => { - const file = NodePath.join(home, `${owner}-${mode}.cjs`); + 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` + @@ -91,12 +92,21 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); try { - const manual = await invoke("one", "start"); + 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 = await invoke("one", "agent-start"); + 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); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 152dfb807878..91fab3d97709 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -43,18 +43,12 @@ const stopHub = hub => { 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 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'; +async function acquireLock(lock, complete = () => false) { const deadline = Date.now() + 600000; while (true) { - try { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), String(process.pid)); break; } catch (error) { + try { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), String(process.pid)); return true; } catch (error) { if (error.code !== 'EEXIST') throw error; - if (complete()) return file; + if (complete()) return false; try { const pid = Number(fs.readFileSync(path.join(lock, 'pid'), 'utf8')); if (!Number.isSafeInteger(pid) || pid <= 0) throw Object.assign(Error('Invalid installer PID'), { code: 'INVALID_PID' }); @@ -66,10 +60,19 @@ async function install(name, version, entry) { try { stale = Date.now() - fs.statSync(lock).mtimeMs > 30000; } catch (error) { if (error.code === 'ENOENT') continue; throw error; } if (error.code === 'ESRCH' || (incomplete && stale)) { fs.rmSync(lock, { recursive: true, force: true }); continue; } } - if (Date.now() > deadline) throw Error('Tool installation is locked at ' + lock + '. Check the other installer before removing the lock.'); + 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'; + if (!await acquireLock(lock, complete)) return file; let staging; try { if (complete()) return file; @@ -98,6 +101,11 @@ async function install(name, version, entry) { 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'); + await acquireLock(hostLock); + try { const hubFile = path.join(state, 'hub.json'); const daemonFile = path.join(state, 'daemon.json'); if (mode === 'stop' || mode === 'stop-agent') { @@ -158,5 +166,6 @@ async function install(name, version, entry) { 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 { fs.rmSync(hostLock, { recursive: true, force: true }); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); `; From fcd983e554a1d5192e33dbc37d702f99847b86de Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:22:31 -0700 Subject: [PATCH 10/15] fix(devices): retry competing SSH binds and ignore removed hosts --- apps/server/src/device/DeviceService.ts | 2 +- apps/server/src/device/SshDeviceHost.test.ts | 12 ++++++++++++ apps/server/src/device/SshDeviceHost.ts | 18 +++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 3dfb898117d4..0eccb6bfb34d 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -391,7 +391,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled || hosts.get(ready.hostId) !== host) + if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index 877f71823c97..d968d3383fae 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -5,6 +5,7 @@ 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"; @@ -20,6 +21,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ 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* () { @@ -27,6 +29,15 @@ it.effect("preserves installed status after probes and cleans failed agent activ 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(() => { @@ -100,6 +111,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); 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); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 954f8a77065a..4ccda0fdefe9 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -163,7 +163,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ), ); - const connect = Effect.fn("SshDeviceHost.connect")(function* (): Effect.fn.Return< + const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, DeviceHost.DeviceHostError > { @@ -330,6 +330,22 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( 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* () { From 1afb8c1397618d31395d50272332efe23cb9d3ae Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:30:27 -0700 Subject: [PATCH 11/15] fix(devices): publish lock ownership atomically and preserve probe causes --- apps/server/src/device/DeviceService.ts | 7 +++- .../server/src/device/sshDeviceScript.test.ts | 3 +- apps/server/src/device/sshDeviceScript.ts | 38 +++++++++++-------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 0eccb6bfb34d..9ae667cd517e 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -894,7 +894,12 @@ export const make = Effect.gen(function* () { SshDeviceHost.probe(host).pipe( Effect.provide(probeContext), Effect.mapError( - (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + (error) => + new DeviceOperationError({ + operation: "probe host", + reason: "request_failed", + cause: error, + }), ), ), configureAgent, diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 914618379986..b62ec637dc05 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -82,8 +82,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); const installLock = hubDir + ".lock"; - await NodeFSP.mkdir(installLock); - await NodeFSP.utimes(installLock, 1, 1); + 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});`, diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 91fab3d97709..d2350c810c35 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -45,20 +45,25 @@ const healthy = async (port, route) => { try { return (await fetch('http://127.0 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 { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), String(process.pid)); return true; } catch (error) { + 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 false; - try { - const pid = Number(fs.readFileSync(path.join(lock, 'pid'), 'utf8')); - if (!Number.isSafeInteger(pid) || pid <= 0) throw Object.assign(Error('Invalid installer PID'), { code: 'INVALID_PID' }); - process.kill(pid, 0); - } catch (error) { - // A new owner may be between mkdir and writing its PID. Reclaim incomplete locks only after a grace period. - const incomplete = error.code === 'ENOENT' || error.code === 'INVALID_PID'; - let stale = false; - try { stale = Date.now() - fs.statSync(lock).mtimeMs > 30000; } catch (error) { if (error.code === 'ENOENT') continue; throw error; } - if (error.code === 'ESRCH' || (incomplete && stale)) { fs.rmSync(lock, { recursive: true, force: true }); continue; } + 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); @@ -72,7 +77,8 @@ async function install(name, version, entry) { if (complete()) return file; fs.mkdirSync(path.dirname(dir), { recursive: true }); const lock = dir + '.lock'; - if (!await acquireLock(lock, complete)) return file; + const release = await acquireLock(lock, complete); + if (!release) return file; let staging; try { if (complete()) return file; @@ -86,7 +92,7 @@ async function install(name, version, entry) { return file; } finally { if (staging) fs.rmSync(staging, { recursive: true, force: true }); - fs.rmSync(lock, { recursive: true, force: true }); + release(); } } (async () => { @@ -104,7 +110,7 @@ async function install(name, version, entry) { 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'); - await acquireLock(hostLock); + const releaseHost = await acquireLock(hostLock); try { const hubFile = path.join(state, 'hub.json'); const daemonFile = path.join(state, 'daemon.json'); @@ -166,6 +172,6 @@ async function install(name, version, entry) { 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 { fs.rmSync(hostLock, { recursive: true, force: true }); } + } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); `; From 210094886de7239972bcc71b5e5b35eb5f6d0898 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:34:03 -0700 Subject: [PATCH 12/15] refactor(devices): report unavailable probe capability without a fabricated cause --- apps/server/src/device/DeviceService.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 9ae667cd517e..e2241bc8b34b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -161,12 +161,11 @@ const vendorPrefix = (platform: DevicePlatform) => export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, - testHost: DeviceService["Service"]["testHost"] = () => + testHost: DeviceService["Service"]["testHost"] = (host) => Effect.fail( - new DeviceOperationError({ - operation: "test host", - reason: "request_failed", - cause: new Error("SSH unavailable"), + new DeviceHostUnavailableError({ + hostId: host.id, + reason: "SSH probing is unavailable in this device service.", }), ), configureAgent: ( From de74c36ba49bf0073374eea10db5604a91cf2178 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:40:00 -0700 Subject: [PATCH 13/15] fix(devices): isolate host cleanup and preserve daemon shutdown across upgrades --- .../server/src/device/DeviceMultiHost.test.ts | 7 +- apps/server/src/device/DeviceService.ts | 133 ++++++++++-------- .../server/src/device/sshDeviceScript.test.ts | 19 ++- apps/server/src/device/sshDeviceScript.ts | 4 +- 4 files changed, 103 insertions(+), 60 deletions(-) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index b687525c92cf..7a40e1e53c72 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -23,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: () => @@ -74,6 +74,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", 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"); @@ -110,6 +111,8 @@ it.effect("keeps hosts independent when serials collide and another host fails", 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"); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index e2241bc8b34b..a60cda76c648 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -232,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) { @@ -291,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( @@ -907,55 +912,73 @@ export const make = Effect.gen(function* () { yield* Effect.context>>(); const configured = new Map(); const reconcile = (next: ReadonlyArray) => - service.withLifecycleLock( - Effect.gen(function* () { - 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, + 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); - yield* Scope.close(previous.scope, Exit.void); - yield* fs - .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true }) - .pipe(Effect.ignore); - } - for (const host of next) { - if (configured.has(host.id)) continue; - const hostScope = yield* Scope.make(); - 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, - }), + 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; - }), - ); + (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( diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index b62ec637dc05..e06cc3b58e1e 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -57,7 +57,7 @@ const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=f 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')); try {process.kill(data.pid,'SIGTERM')} catch {} } +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.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(); } `, @@ -126,7 +126,22 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr .split("\n"); expect(stopped).toContain(String(firstHub.pid)); expect(stopped).not.toContain(String(secondHub.pid)); - await invoke("one", "stop-agent"); + // 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); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index d2350c810c35..0c406d4278d5 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -114,13 +114,14 @@ async function install(name, version, entry) { 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 = path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); + 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; } @@ -157,6 +158,7 @@ async function install(name, version, entry) { let agentResult = {}; if (mode === 'agent-start') { const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); + write(agentFile, { entryPath: agentEntry }); let daemon = read(daemonFile); if (!daemon || !await healthy(daemon.httpPort, '/health')) { fs.rmSync(daemonFile, { force: true }); From ca19421b3c8f3f88aced0d513dffcf299193e811 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:50:46 -0700 Subject: [PATCH 14/15] fix(devices): replace remote helpers when their pinned versions change --- .../server/src/device/sshDeviceScript.test.ts | 42 +++++++++++++++++-- apps/server/src/device/sshDeviceScript.ts | 13 ++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index e06cc3b58e1e..66306c2e684f 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -58,20 +58,25 @@ 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.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 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), + 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}` }, @@ -119,13 +124,44 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), ); await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); - const repaired = await invoke("one", "agent-start"); + 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"); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 0c406d4278d5..d91929644e42 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -129,7 +129,7 @@ async function install(name, version, entry) { 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 || !await healthy(hub.port, '/readyz')) { + 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(); @@ -158,9 +158,15 @@ async function install(name, version, entry) { let agentResult = {}; if (mode === 'agent-start') { const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); - write(agentFile, { entryPath: agentEntry }); + const previousAgent = read(agentFile)?.entryPath; let daemon = read(daemonFile); - if (!daemon || !await healthy(daemon.httpPort, '/health')) { + 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; @@ -168,6 +174,7 @@ async function install(name, version, entry) { 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'); From a6c04e681ebfb6efc1fad89daa5e3833cafe3ee3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:00:03 -0700 Subject: [PATCH 15/15] fix(devices): clarify remote host setup and per-host feedback --- .../server/src/device/sshDeviceScript.test.ts | 26 +- apps/server/src/device/sshDeviceScript.ts | 6 + .../device/DeviceHostAvailability.tsx | 27 ++ .../settings/DeviceHostsSettings.tsx | 237 ++++++++++++------ .../settings/IntegrationsSettings.tsx | 72 +++++- .../settings/ProjectDefaultsSettings.tsx | 6 - .../src/components/settings/settingsSearch.ts | 2 +- docs/user/devices.md | 8 +- 8 files changed, 292 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/components/device/DeviceHostAvailability.tsx diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 66306c2e684f..eadd85fd047d 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -7,11 +7,35 @@ 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, remoteDeviceScript } from "./sshDeviceScript.ts"; +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; diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index d91929644e42..bbdb828c1a74 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -9,6 +9,12 @@ if [ -z "$ANDROID_HOME" ]; then 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. */ diff --git a/apps/web/src/components/device/DeviceHostAvailability.tsx b/apps/web/src/components/device/DeviceHostAvailability.tsx new file mode 100644 index 000000000000..03a0b175dae8 --- /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 index f8c1428a463f..e61cf742dfd5 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,4 +1,12 @@ -import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; +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"; @@ -7,7 +15,9 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SettingsSection } from "./settingsLayout"; +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: { @@ -21,7 +31,14 @@ export function DeviceHostsSettings(props: { const [busy, setBusy] = useState(false); const validPort = (port: number | undefined) => port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); - const [result, setResult] = useState(null); + 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); @@ -32,44 +49,45 @@ export function DeviceHostsSettings(props: { }); if (saved._tag === "Success") { setEditing(null); - setResult(null); } } finally { setBusy(false); } }; const testConnection = async (host: SshDeviceHostConfig) => { - if (!props.environmentId) return; - setBusy(true); - setResult(null); + if (!props.environmentId || checks[host.id]?.pending) return; + setCheck(host.id, { pending: true }); try { const summary = await test({ environmentId: props.environmentId, input: host }); - if (summary._tag === "Failure") { - setResult(Cause.pretty(summary.cause)); - return; - } - setResult( - summary.value.platforms - .map((platform) => - platform.available - ? `${platform.platform === "ios" ? "iOS" : "Android"} available` - : platform.reason, - ) - .join(". "), + setCheck( + host.id, + summary._tag === "Failure" + ? { error: Cause.pretty(summary.cause) } + : { platforms: summary.value.platforms }, ); } catch (error) { - setResult(error instanceof Error ? error.message : String(error)); - } finally { - setBusy(false); + setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); } }; return ( - -
-

- Connect simulators on other machines over SSH. Keys, aliases, and paths are read on the - selected environment. Device tools install there on first use. -

+ { + setEditing({ id: randomUUID(), label: "", target: "" }); + }} + > + Add host + + } + > +
{!props.environmentId ? (

Select one connected environment to manage its device hosts. @@ -78,54 +96,122 @@ export function DeviceHostsSettings(props: { <> {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}

-

- {host.target} - {status ? ` · ${status.status}` : ""} -

- {status?.detail ? ( -

{status.detail}

+
+

{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]); @@ -216,34 +302,33 @@ export function DeviceHostsSettings(props: { disabled={busy} onClick={() => { setEditing(null); - setResult(null); }} > Cancel
+ {checks[editing.id]?.pending ? ( + + + Checking connection… + + ) : null} + {checks[editing.id]?.platforms ? ( + + ) : null} + {checks[editing.id]?.error ? ( +

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

+ ) : null} - ) : ( - - )} - {result ? ( -

- {result} -

) : null} )}
-
+
); } diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3f76f49cd5f..8cb21532a426 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/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 3e7fd536953a..938000e01002 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -30,7 +30,6 @@ import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { DeviceHostsSettings } from "./DeviceHostsSettings"; import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; import { searchableSetting } from "./settingsSearch"; @@ -390,11 +389,6 @@ export function ProjectDefaultsSettings({ } />
-