History

feat(devices): connect simulator hosts over SSH (#10856)

Julius Marminge committed 2 months ago

Commit d2eeacd1 parent 7734c6d30 files · +1794 −78Browse files

Unified · Split

30 files
  1. apps/server/package.json +1 −0
  2. apps/server/src/auth/RpcAuthorization.ts +1 −0
  3. apps/server/src/device/DeviceActions.test.ts +1 −0
  4. apps/server/src/device/DeviceActions.ts +1 −1
  5. apps/server/src/device/DeviceHost.ts +2 −1
  6. apps/server/src/device/DeviceMultiHost.test.ts +47 −7
  7. apps/server/src/device/DeviceService.test.ts +1 −0
  8. apps/server/src/device/DeviceService.ts +220 −52
  9. apps/server/src/device/DeviceToolchain.ts +2 −2
  10. apps/server/src/device/LocalDeviceHost.ts +1 −0
  11. apps/server/src/device/SshDeviceHost.test.ts +131 −0
  12. apps/server/src/device/SshDeviceHost.ts +434 −0
  13. apps/server/src/device/sshDeviceScript.test.ts +220 −0
  14. apps/server/src/device/sshDeviceScript.ts +192 −0
  15. apps/server/src/mcp/McpDeviceToolkit.test.ts +1 −0
  16. apps/server/src/mcp/toolkits/device/handlers.ts +3 −0
  17. apps/server/src/ws.ts +4 −0
  18. apps/web/src/components/device/DeviceHostAvailability.tsx +27 −0
  19. apps/web/src/components/settings/DeviceHostsSettings.tsx +334 −0
  20. apps/web/src/components/settings/IntegrationsSettings.tsx +66 −6
  21. apps/web/src/components/settings/settingsSearch.ts +6 −0
  22. docs/internals/devices.md +5 −7
  23. docs/user/devices.md +28 −1
  24. packages/client-runtime/src/state/device.ts +4 −0
  25. packages/contracts/src/device.ts +23 −1
  26. packages/contracts/src/rpc.ts +10 −0
  27. packages/contracts/src/settings.test.ts +13 −0
  28. packages/contracts/src/settings.ts +3 −0
  29. packages/shared/src/serverSettings.test.ts +10 −0
  30. pnpm-lock.yaml +3 −0
apps/server/package.json+1−0
@@ -40,6 +40,7 @@ · 39 unmodified lines
4040 "@effect/vitest": "catalog:",
4141 "@t3tools/contracts": "workspace:*",
4242 "@t3tools/shared": "workspace:*",
43+ "@t3tools/ssh": "workspace:*",
4344 "@t3tools/tailscale": "workspace:*",
4445 "@t3tools/web": "workspace:*",
4546 "@types/bun": "1.3.14",
apps/server/src/auth/RpcAuthorization.ts+1−0
@@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = { · 144 unmodified lines
145145 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
146146 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
147147 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
148+ [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
148149 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,
149150 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
150151 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
apps/server/src/device/DeviceActions.test.ts+1−0
@@ -16,6 +16,7 @@ const makeReady = ( · 15 unmodified lines
1616 ) => {
1717 const calls: Call[] = [];
1818 const ready: DeviceHostReady = {
19+ nodePath: process.execPath,
1920 hub: { origin: "http://127.0.0.1:1" },
2021 helpers,
2122 run: (command, args, options) => {
apps/server/src/device/DeviceActions.ts+1−1
@@ -327,7 +327,7 @@ const serveSimPermissions = ( · 326 unmodified lines
327327 reason: "helper_missing",
328328 });
329329 yield* ready
330− .run(process.execPath, [
330+ .run(ready.nodePath, [
331331 cli,
332332 "permissions",
333333 input.decision,
apps/server/src/device/DeviceHost.ts+2−1
@@ -46,11 +46,12 @@ export interface DeviceHubEndpoint { · 45 unmodified lines
4646 export interface AgentDeviceEndpoint {
4747 readonly baseUrl: string;
4848 readonly token: string;
49− /** Absolute path of the agent-device entry script for the provider PATH shim. */
49+ /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */
5050 readonly entryPath: string;
5151 }
5252
5353 export interface DeviceHostReady {
54+ readonly nodePath: string;
5455 readonly hub: DeviceHubEndpoint;
5556 /**
5657 * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub)
apps/server/src/device/DeviceMultiHost.test.ts+47−7
@@ -1,5 +1,7 @@
11 import { expect, it } from "@effect/vitest";
22 import { ThreadId } from "@t3tools/contracts";
3+import * as Deferred from "effect/Deferred";
4+import * as Fiber from "effect/Fiber";
35 import * as Effect from "effect/Effect";
46 import { HttpClient, HttpClientResponse } from "effect/unstable/http";
57 import { ServerSettingsService } from "../serverSettings.ts";
@@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 4 unmodified lines
1012 Effect.gen(function* () {
1113 const host = (id: string, failed = false): DeviceHost["Service"] => {
1214 const ready = {
15+ nodePath: process.execPath,
1316 hub: { origin: `http://${id}` },
1417 agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
1518 run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
@@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 4 unmodified lines
2023 summary: Effect.succeed({
2124 id,
2225 label: id,
23− kind: "local",
26+ kind: id === "b" ? "ssh" : "local",
2427 hubInstalled: true,
2528 agentDeviceInstalled: true,
26− platforms: [{ platform: "android", available: true }],
29+ platforms: id === "b" ? [] : [{ platform: "android", available: true }],
2730 }),
2831 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
2932 ensureReady: () =>
@@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 29 unmodified lines
5962 ),
6063 );
6164 const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
62− const service = yield* makeWithHosts(hosts).pipe(
63− Effect.provideService(HttpClient.HttpClient, http),
64− );
65+ const writeStarted = yield* Deferred.make<void>();
66+ const finishWrite = yield* Deferred.make<void>();
67+ const order: string[] = [];
68+ const service = yield* makeWithHosts(hosts, undefined, () =>
69+ Effect.gen(function* () {
70+ order.push("write started");
71+ yield* Deferred.succeed(writeStarted, undefined);
72+ yield* Deferred.await(finishWrite);
73+ order.push("write finished");
74+ return "/host-config.json";
75+ }),
76+ ).pipe(Effect.provideService(HttpClient.HttpClient, http));
77+ expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();
6578 const listed = yield* service.list;
6679 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
6780 expect(listed.hostStatuses.offline?.status).toBe("failed");
@@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 6 unmodified lines
7487 expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
7588 expect(state.hostStatuses.a?.status).toBe("ready");
7689 expect(state.hostStatuses.offline?.status).toBe("failed");
77− yield* service.agentReadinessIfSupported("b");
78− expect((yield* service.state).hostStatuses.b?.status).toBe("ready");
90+ const targeting = yield* service
91+ .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" })
92+ .pipe(Effect.forkChild);
93+ yield* Deferred.await(writeStarted);
94+ const replacing = yield* service
95+ .withLifecycleLock(
96+ Effect.gen(function* () {
97+ order.push("replace");
98+ hosts.set("b", host("b"));
99+ yield* service.refreshHosts;
100+ }),
101+ )
102+ .pipe(Effect.forkChild);
103+ yield* Deferred.succeed(finishWrite, undefined);
104+ yield* Fiber.join(targeting);
105+ yield* Fiber.join(replacing);
106+ expect(order).toEqual(["write started", "write finished", "replace"]);
107+ const replaced = yield* service.state;
108+ expect(replaced.sessions).toEqual([]);
109+ expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]);
110+ expect(replaced.hostStatuses.b).toBeUndefined();
111+ yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" });
112+ hosts.delete("b");
113+ yield* service.refreshHosts;
114+ yield* service.setHostStatus("b", { status: "ready" });
115+ expect((yield* service.state).hostStatuses.b).toBeUndefined();
116+ expect((yield* service.state).sessions).toEqual([]);
117+ yield* service.agentReadinessIfSupported("a");
118+ expect((yield* service.state).hostStatuses.a?.status).toBe("ready");
79119 yield* service.configure({ enabled: false });
80120 expect((yield* service.state).hostStatuses).toEqual({});
81121 }).pipe(
apps/server/src/device/DeviceService.test.ts+1−0
@@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( · 69 unmodified lines
7070 let booted = false;
7171 let shutDown = false;
7272 const ready: DeviceHost.DeviceHostReady = {
73+ nodePath: process.execPath,
7374 hub: { origin: "http://device.test" },
7475 helpers: { serveSimAxSettings: null, serveSimCli: null },
7576 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
apps/server/src/device/DeviceService.ts+220−52
@@ -30,6 +30,8 @@ import { · 29 unmodified lines
3030 type DeviceSession,
3131 type DeviceShutdownInput,
3232 type DeviceSummary,
33+ type SshDeviceHostConfig,
34+ type DeviceHostSummary,
3335 LOCAL_DEVICE_HOST_ID,
3436 type ThreadId,
3537 } from "@t3tools/contracts";
@@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts"; · 24 unmodified lines
6062 import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
6163 import * as ProcessRunner from "../processRunner.ts";
6264 import * as DeviceHost from "./DeviceHost.ts";
65+import * as SshDeviceHost from "./SshDeviceHost.ts";
66+import * as Exit from "effect/Exit";
6367 import * as LocalDeviceHost from "./LocalDeviceHost.ts";
6468
6569 /** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
@@ -105,6 +109,9 @@ export class DeviceService extends Context.Service< · 39 unmodified lines
105109 DeviceService,
106110 {
107111 readonly agentCli: Effect.Effect<string, DeviceError>;
112+ readonly testHost: (
113+ config: SshDeviceHostConfig,
114+ ) => Effect.Effect<DeviceHostSummary, DeviceError>;
108115 readonly agentTarget: (input: {
109116 threadId: ThreadId;
110117 hostId: DeviceHostId;
@@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) => · 43 unmodified lines
154161
155162 export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
156163 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
164+ testHost: DeviceService["Service"]["testHost"] = (host) =>
165+ Effect.fail(
166+ new DeviceHostUnavailableError({
167+ hostId: host.id,
168+ reason: "SSH probing is unavailable in this device service.",
169+ }),
170+ ),
157171 configureAgent: (
158172 hostId: DeviceHostId,
159173 ready: DeviceHost.DeviceHostAgentReady,
@@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 23 unmodified lines
183197 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
184198 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
185199 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
200+ let publishedHosts = new Map(hosts);
186201 const stateRef = yield* SynchronizedRef.make<ServiceState>({
187202 state: {
188203 hosts: initialHosts,
@@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 28 unmodified lines
217232 hostId: DeviceHostId,
218233 status: DeviceServiceState["hostStatuses"][string],
219234 ) =>
220− publish((state) => ({
221− ...state,
222− ...(hostId === LOCAL_DEVICE_HOST_ID
223− ? { hostStatus: status.status, hostStatusDetail: status.detail }
224− : {}),
225− hostStatuses: { ...state.hostStatuses, [hostId]: status },
226− }));
235+ Effect.suspend(() =>
236+ !hosts.has(hostId)
237+ ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))
238+ : publish((state) => ({
239+ ...state,
240+ ...(hostId === LOCAL_DEVICE_HOST_ID
241+ ? { hostStatus: status.status, hostStatusDetail: status.detail }
242+ : {}),
243+ hostStatuses: { ...state.hostStatuses, [hostId]: status },
244+ })),
245+ );
227246
228247 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
229248 function* (hostId) {
@@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 15 unmodified lines
245264 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
246265 ),
247266 );
267+ if (hosts.get(host.id) !== host)
268+ return yield* new DeviceHostUnavailableError({
269+ hostId: host.id,
270+ reason: "Host configuration changed. Retry the operation.",
271+ });
248272 const { state } = yield* SynchronizedRef.get(stateRef);
249273 if (state.hostStatuses[host.id]?.status !== "ready") {
250274 yield* setHostStatus(host.id, { status: "ready" });
@@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 9 unmodified lines
260284 if (!(yield* readDeviceSettings).enabled) return null;
261285 const host = yield* resolveHost(hostId);
262286 const summary = yield* host.summary;
263− if (!summary.platforms.some((platform) => platform.available)) return null;
287+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
288+ return null;
264289 return yield* readiness(host.id);
265290 });
266291
@@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 3 unmodified lines
270295 if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
271296 const host = yield* resolveHost(hostId);
272297 const summary = yield* host.summary;
273− if (!summary.platforms.some((platform) => platform.available)) return null;
298+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
299+ return null;
274300 const ready = yield* host
275301 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
276302 .pipe(
@@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 87 unmodified lines
364390 });
365391
366392 const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
393+ const host = hosts.get(ready.hostId);
367394 const { devices, detail } = yield* fetchDevices(ready);
368395 const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
369396 return yield* lifecycleLock.withPermit(
370397 Effect.gen(function* () {
371− if (!(yield* readDeviceSettings).enabled)
398+ if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
372399 return (yield* SynchronizedRef.get(stateRef)).state;
373400 return yield* publish((state) => ({
374401 ...state,
@@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 203 unmodified lines
578605 ),
579606 );
580607 }
608+ if (hosts.get(host.id) !== host)
609+ return yield* new DeviceHostUnavailableError({
610+ hostId: host.id,
611+ reason: "Host configuration changed. Retry the operation.",
612+ });
581613 const openedAt = DateTime.formatIso(yield* DateTime.now);
582614 const session: DeviceSession = {
583615 threadId: input.threadId,
@@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 174 unmodified lines
758790 Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
759791 );
760792
761− return DeviceService.of({
762− agentCli: Effect.fail(
763− new DeviceHostUnavailableError({
764− hostId: LOCAL_DEVICE_HOST_ID,
765− reason: "Agent CLI installation is unavailable in this device service.",
766− }),
767− ),
768− agentTarget: (input) =>
769− Effect.gen(function* () {
770− const ready = yield* agentReadinessIfSupported(input.hostId);
771− if (!ready)
772− return yield* new DeviceHostUnavailableError({
773− hostId: input.hostId,
774− reason:
775− "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
776− });
777− const configPath = yield* configureAgent(input.hostId, ready);
778− return [
779− "--config",
780− configPath,
781− "--session",
782− agentDeviceSession(input.threadId, input.hostId, input.deviceId),
783− ];
784− }),
785− state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
786− subscribe: PubSub.subscribe(statePubSub),
787− configure,
788− list,
789− open,
790− close,
791− shutdown,
792− detail,
793− action,
794− screenshot,
795− readiness,
796− readinessIfSupported,
797− agentReadinessIfSupported,
798− currentReadiness,
799− sessionsForThread,
800− });
793+ return {
794+ ...DeviceService.of({
795+ testHost,
796+ agentCli: Effect.fail(
797+ new DeviceHostUnavailableError({
798+ hostId: LOCAL_DEVICE_HOST_ID,
799+ reason: "Agent CLI installation is unavailable in this device service.",
800+ }),
801+ ),
802+ agentTarget: (input) =>
803+ Effect.gen(function* () {
804+ const host = yield* resolveHost(input.hostId);
805+ const ready = yield* agentReadinessIfSupported(input.hostId);
806+ if (!ready)
807+ return yield* new DeviceHostUnavailableError({
808+ hostId: input.hostId,
809+ reason:
810+ "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
811+ });
812+ const configPath = yield* lifecycleLock.withPermit(
813+ Effect.gen(function* () {
814+ if (hosts.get(host.id) !== host)
815+ return yield* new DeviceHostUnavailableError({
816+ hostId: host.id,
817+ reason: "Host configuration changed. Retry the operation.",
818+ });
819+ return yield* configureAgent(input.hostId, ready);
820+ }),
821+ );
822+ return [
823+ "--config",
824+ configPath,
825+ "--session",
826+ agentDeviceSession(input.threadId, input.hostId, input.deviceId),
827+ ];
828+ }),
829+ state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
830+ subscribe: PubSub.subscribe(statePubSub),
831+ configure,
832+ list,
833+ open,
834+ close,
835+ shutdown,
836+ detail,
837+ action,
838+ screenshot,
839+ readiness,
840+ readinessIfSupported,
841+ agentReadinessIfSupported,
842+ currentReadiness,
843+ sessionsForThread,
844+ }),
845+ setHostStatus,
846+ withLifecycleLock: lifecycleLock.withPermit,
847+ refreshHosts: Effect.gen(function* () {
848+ const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
849+ const unchanged = (id: DeviceHostId) =>
850+ hosts.has(id) && hosts.get(id) === publishedHosts.get(id);
851+ yield* publish((state) => ({
852+ ...state,
853+ hosts: summaries,
854+ hostStatuses: Object.fromEntries(
855+ Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)),
856+ ),
857+ devices: state.devices.filter((device) => unchanged(device.hostId)),
858+ sessions: state.sessions.filter((session) => unchanged(session.hostId)),
859+ }));
860+ publishedHosts = new Map(hosts);
861+ }),
862+ };
801863 });
802864
803865 /** @public Service construction is part of the canonical Effect module API. */
@@ -807,7 +869,12 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
807869 const fs = yield* FileSystem.FileSystem;
808870 const path = yield* Path.Path;
809871 const runner = yield* ProcessRunner.ProcessRunner;
810− const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => {
872+ const settings = yield* ServerSettings.ServerSettingsService;
873+ const scope = yield* Scope.Scope;
874+ const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([
875+ [localHost.id, localHost],
876+ ]);
877+ const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => {
811878 const file = agentDeviceConfigPath(config.stateDir, hostId, path);
812879 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
813880 Effect.provideService(FileSystem.FileSystem, fs),
@@ -822,7 +889,108 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
822889 ),
823890 Effect.as(file),
824891 );
825− });
892+ };
893+ const probeContext =
894+ yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
895+ const service = yield* makeWithHosts(
896+ hosts,
897+ (host) =>
898+ SshDeviceHost.probe(host).pipe(
899+ Effect.provide(probeContext),
900+ Effect.mapError(
901+ (error) =>
902+ new DeviceOperationError({
903+ operation: "probe host",
904+ reason: "request_failed",
905+ cause: error,
906+ }),
907+ ),
908+ ),
909+ configureAgent,
910+ );
911+ const hostContext =
912+ yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
913+ const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>();
914+ const reconcile = (next: ReadonlyArray<SshDeviceHostConfig>) =>
915+ Effect.gen(function* () {
916+ const removed = yield* service.withLifecycleLock(
917+ Effect.gen(function* () {
918+ const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
919+ for (const [id, previous] of configured) {
920+ if (
921+ next.some(
922+ (host) =>
923+ host.id === id &&
924+ host.label === previous.config.label &&
925+ host.target === previous.config.target &&
926+ host.port === previous.config.port &&
927+ host.identityFile === previous.config.identityFile,
928+ )
929+ )
930+ continue;
931+ hosts.delete(id);
932+ configured.delete(id);
933+ removed.push({ id, scope: previous.scope });
934+ }
935+ yield* service.refreshHosts;
936+ return removed;
937+ }),
938+ );
939+ // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts.
940+ yield* Effect.forEach(
941+ removed,
942+ ({ id, scope }) =>
943+ Effect.gen(function* () {
944+ yield* Scope.close(scope, Exit.void);
945+ yield* fs
946+ .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true })
947+ .pipe(Effect.ignore);
948+ }),
949+ { concurrency: 4, discard: true },
950+ );
951+ yield* service.withLifecycleLock(
952+ Effect.gen(function* () {
953+ for (const host of next) {
954+ if (configured.has(host.id)) continue;
955+ const hostScope = yield* Scope.fork(scope);
956+ const instance = yield* SshDeviceHost.make(
957+ host,
958+ (ready) =>
959+ configureAgent(host.id, ready).pipe(
960+ Effect.asVoid,
961+ Effect.mapError(
962+ (error) =>
963+ new DeviceHost.DeviceHostError({
964+ hostId: host.id,
965+ step: "configuring agent access",
966+ cause: error,
967+ }),
968+ ),
969+ ),
970+ (status, detail) =>
971+ service
972+ .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) })
973+ .pipe(Effect.asVoid),
974+ ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext));
975+ hosts.set(host.id, instance);
976+ configured.set(host.id, { config: host, scope: hostScope });
977+ }
978+ yield* service.refreshHosts;
979+ }),
980+ );
981+ });
982+ const changes = yield* settings.subscribeChanges;
983+ yield* reconcile((yield* settings.getSettings).deviceHosts);
984+ yield* changes.pipe(
985+ Stream.runForEach((value) => reconcile(value.deviceHosts)),
986+ Effect.forkIn(scope),
987+ );
988+ yield* Effect.addFinalizer(() =>
989+ Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), {
990+ discard: true,
991+ concurrency: 4,
992+ }),
993+ );
826994 return {
827995 ...service,
828996 agentCli: ensureAgentDevice(config.baseDir).pipe(
apps/server/src/device/DeviceToolchain.ts+2−2
@@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore"; · 24 unmodified lines
2525 import * as ProcessRunner from "../processRunner.ts";
2626
2727 const DEVICE_HUB_PACKAGE = "expo-device-hub";
28−const DEVICE_HUB_VERSION = "0.9.0";
28+export const DEVICE_HUB_VERSION = "0.9.0";
2929 const AGENT_DEVICE_PACKAGE = "agent-device";
30−const AGENT_DEVICE_VERSION = "0.20.10";
30+export const AGENT_DEVICE_VERSION = "0.20.10";
3131
3232 const INSTALL_TIMEOUT = Duration.minutes(10);
3333 const installLock = Semaphore.makeUnsafe(1);
apps/server/src/device/LocalDeviceHost.ts+1−0
@@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { · 643 unmodified lines
644644
645645 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
646646 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
647+ nodePath: process.execPath,
647648 run,
648649 helpers: running.helpers,
649650 });
apps/server/src/device/SshDeviceHost.test.ts+131−0
@@ -0,0 +1,131 @@
1+// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout.
2+import { expect, it } from "@effect/vitest";
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import * as Net from "@t3tools/shared/Net";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as PlatformError from "effect/PlatformError";
9+import * as Sink from "effect/Sink";
10+import * as Stream from "effect/Stream";
11+import * as HttpClient from "effect/unstable/http/HttpClient";
12+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
13+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
14+import * as ServerConfig from "../config.ts";
15+import * as DeviceHost from "./DeviceHost.ts";
16+import * as SshDeviceHost from "./SshDeviceHost.ts";
17+
18+it.effect("preserves installed status after probes and cleans failed agent activation", () =>
19+ Effect.gen(function* () {
20+ const fs = yield* FileSystem.FileSystem;
21+ const home = yield* fs.makeTempDirectoryScoped();
22+ const modes: string[] = [];
23+ let forwards = 0;
24+ let failForward = true;
25+ let rejectConfig = true;
26+ const spawner = ChildProcessSpawner.make((command) =>
27+ Effect.gen(function* () {
28+ if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
29+ const forwarding = command.args.includes("-N");
30+ let output = "";
31+ if (forwarding) {
32+ if (failForward) {
33+ failForward = false;
34+ return yield* PlatformError.systemError({
35+ _tag: "AlreadyExists",
36+ module: "ChildProcess",
37+ method: "spawn",
38+ description: "Port already bound",
39+ });
40+ }
41+ forwards++;
42+ yield* Effect.addFinalizer(() =>
43+ Effect.sync(() => {
44+ forwards--;
45+ }),
46+ );
47+ } else {
48+ const stdin = command.options.stdin;
49+ if (
50+ !stdin ||
51+ typeof stdin !== "object" ||
52+ !("stream" in stdin) ||
53+ !Stream.isStream(stdin.stream)
54+ )
55+ return yield* Effect.die("Missing script");
56+ const script = yield* stdin.stream.pipe(
57+ Stream.decodeText(),
58+ Stream.runFold(
59+ () => "",
60+ (a, b) => a + b,
61+ ),
62+ );
63+ const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
64+ modes.push(mode);
65+ output = JSON.stringify({
66+ nodePath: "/node",
67+ platforms: [{ platform: "ios", available: true }],
68+ hubPort: 1234,
69+ helpers: { serveSimAxSettings: null, serveSimCli: null },
70+ ...(mode === "agent-start"
71+ ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" }
72+ : {}),
73+ });
74+ }
75+ return ChildProcessSpawner.makeHandle({
76+ pid: ChildProcessSpawner.ProcessId(123),
77+ stdout: Stream.make(new TextEncoder().encode(output)),
78+ stderr: Stream.empty,
79+ all: Stream.empty,
80+ exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
81+ isRunning: Effect.succeed(forwarding),
82+ kill: () => Effect.void,
83+ stdin: Sink.drain,
84+ getInputFd: () => Sink.drain,
85+ getOutputFd: () => Stream.empty,
86+ unref: Effect.succeed(Effect.void),
87+ });
88+ }),
89+ );
90+ const host = yield* SshDeviceHost.make(
91+ { id: "test", label: "Test", target: "test.example" },
92+ () =>
93+ rejectConfig
94+ ? Effect.fail(
95+ new DeviceHost.DeviceHostError({
96+ hostId: "test",
97+ step: "configuring agent access",
98+ cause: new Error("fixture failure"),
99+ }),
100+ )
101+ : Effect.void,
102+ ).pipe(
103+ Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
104+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
105+ Effect.provideService(
106+ HttpClient.HttpClient,
107+ HttpClient.make((request) =>
108+ Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))),
109+ ),
110+ ),
111+ );
112+ yield* host.ensureReady(() => Effect.void);
113+ expect(forwards).toBe(1);
114+ expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
115+ yield* host.platformAvailability("ios");
116+ expect((yield* host.summary).hubInstalled).toBe(true);
117+ const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result);
118+ expect(failed._tag).toBe("Failure");
119+ expect(forwards).toBe(0);
120+ expect(modes.at(-1)).toBe("stop-agent");
121+ expect(yield* host.current).toBeNull();
122+ rejectConfig = false;
123+ yield* host.ensureAgentReady(() => Effect.void);
124+ yield* host.platformAvailability("ios");
125+ expect((yield* host.summary).agentDeviceInstalled).toBe(true);
126+ yield* host.stopAgent;
127+ expect(forwards).toBe(1);
128+ yield* host.stop;
129+ expect(forwards).toBe(0);
130+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
131+);
apps/server/src/device/SshDeviceHost.ts+434−0
@@ -0,0 +1,434 @@
1+import * as NodeCrypto from "node:crypto";
2+import {
3+ type DeviceHostSummary,
4+ DevicePlatformAvailability,
5+ type SshDeviceHostConfig,
6+} from "@t3tools/contracts";
7+import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
8+import * as NetService from "@t3tools/shared/Net";
9+import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
10+import * as Exit from "effect/Exit";
11+import * as Effect from "effect/Effect";
12+import * as FileSystem from "effect/FileSystem";
13+import * as Path from "effect/Path";
14+import * as Scope from "effect/Scope";
15+import * as Semaphore from "effect/Semaphore";
16+import * as Schema from "effect/Schema";
17+import * as Stream from "effect/Stream";
18+import * as HttpClient from "effect/unstable/http/HttpClient";
19+import * as ChildProcess from "effect/unstable/process/ChildProcess";
20+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
21+import * as ServerConfig from "../config.ts";
22+import * as DeviceHost from "./DeviceHost.ts";
23+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
24+
25+const Probe = Schema.Struct({
26+ nodePath: Schema.String,
27+ platforms: Schema.Array(DevicePlatformAvailability),
28+});
29+const Started = Schema.Struct({
30+ ...Probe.fields,
31+ hubPort: Schema.Int,
32+ daemonPort: Schema.optionalKey(Schema.Int),
33+ token: Schema.optionalKey(Schema.String),
34+ entryPath: Schema.optionalKey(Schema.String),
35+ helpers: Schema.Struct({
36+ serveSimAxSettings: Schema.NullOr(Schema.String),
37+ serveSimCli: Schema.NullOr(Schema.String),
38+ }),
39+});
40+const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe));
41+const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started));
42+const targetFor = (config: SshDeviceHostConfig) => ({
43+ alias: config.target,
44+ hostname: config.target,
45+ username: null,
46+ port: config.port ?? null,
47+});
48+const identityArgs = (config: SshDeviceHostConfig) =>
49+ config.identityFile ? ["-i", config.identityFile] : [];
50+const commandArgs = (script: string) => [
51+ "sh",
52+ "-c",
53+ quoteRemoteArg(remoteDeviceEnvironment + script),
54+];
55+const bootstrap = (
56+ config: SshDeviceHostConfig,
57+ owner: string,
58+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
59+) =>
60+ runSshCommand(targetFor(config), {
61+ preHostArgs: identityArgs(config),
62+ remoteCommandArgs: commandArgs(
63+ 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node',
64+ ),
65+ stdin: remoteDeviceScript(owner, mode),
66+ timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000,
67+ }).pipe(
68+ Effect.mapError(
69+ (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }),
70+ ),
71+ );
72+
73+export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
74+ const result = yield* bootstrap(config, "probe", "probe");
75+ const value = yield* decodeProbe(result.stdout.trim()).pipe(
76+ Effect.mapError(
77+ (cause) =>
78+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }),
79+ ),
80+ );
81+ return {
82+ id: config.id,
83+ label: config.label,
84+ kind: "ssh",
85+ hubInstalled: false,
86+ agentDeviceInstalled: false,
87+ platforms: value.platforms,
88+ } satisfies DeviceHostSummary;
89+});
90+
91+export const make = Effect.fn("SshDeviceHost.make")(function* (
92+ config: SshDeviceHostConfig,
93+ onReady: (
94+ ready: DeviceHost.DeviceHostAgentReady,
95+ ) => Effect.Effect<void, DeviceHost.DeviceHostError> = () => Effect.void,
96+ onStatus: (
97+ status: "starting" | "ready" | "failed",
98+ detail?: string,
99+ ) => Effect.Effect<void> = () => Effect.void,
100+) {
101+ const fs = yield* FileSystem.FileSystem;
102+ const path = yield* Path.Path;
103+ const server = yield* ServerConfig.ServerConfig;
104+ const net = yield* NetService.NetService;
105+ const http = yield* HttpClient.HttpClient;
106+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
107+ const parentScope = yield* Scope.Scope;
108+ const ssh = yield* resolveSshCommand;
109+ const environmentId = yield* fs
110+ .readFileString(server.environmentIdPath)
111+ .pipe(Effect.orElseSucceed(() => server.stateDir));
112+ const owner = NodeCrypto.createHash("sha256")
113+ .update(`${environmentId}\0${server.stateDir}\0${config.id}`)
114+ .digest("hex")
115+ .slice(0, 24);
116+ const provide = <A, E>(
117+ effect: Effect.Effect<
118+ A,
119+ E,
120+ FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
121+ >,
122+ ) =>
123+ effect.pipe(
124+ Effect.provideService(FileSystem.FileSystem, fs),
125+ Effect.provideService(Path.Path, path),
126+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
127+ );
128+ const lock = yield* Semaphore.make(1);
129+ let stopped = false;
130+ let activated = false;
131+ let wantsAgent = false;
132+ let ready:
133+ | (DeviceHost.DeviceHostReady & {
134+ agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"];
135+ })
136+ | null = null;
137+ let connectionScope: Scope.Closeable | null = null;
138+ let summary: DeviceHostSummary = {
139+ id: config.id,
140+ label: config.label,
141+ kind: "ssh",
142+ hubInstalled: false,
143+ agentDeviceInstalled: false,
144+ platforms: [],
145+ };
146+
147+ const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
148+ provide(
149+ runSshCommand(targetFor(config), {
150+ preHostArgs: identityArgs(config),
151+ remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`),
152+ ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
153+ ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
154+ }),
155+ ).pipe(
156+ Effect.map((result) => ({ ...result, code: 0 })),
157+ Effect.catch((error) =>
158+ Effect.succeed({
159+ stdout: "stdout" in error ? (error.stdout ?? "") : "",
160+ stderr: error.message,
161+ code: "exitCode" in error ? (error.exitCode ?? 127) : 127,
162+ }),
163+ ),
164+ );
165+
166+ const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return<
167+ DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] },
168+ DeviceHost.DeviceHostError
169+ > {
170+ activated = true;
171+ const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start"));
172+ yield* onStatus("starting");
173+ const remote = yield* decodeStarted(result.stdout.trim()).pipe(
174+ Effect.mapError(
175+ (cause) =>
176+ new DeviceHost.DeviceHostError({
177+ hostId: config.id,
178+ step: "reading host endpoints",
179+ cause,
180+ }),
181+ ),
182+ );
183+ summary = {
184+ ...summary,
185+ platforms: remote.platforms,
186+ hubInstalled: true,
187+ agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
188+ };
189+ const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
190+ Effect.mapError(
191+ (cause) =>
192+ new DeviceHost.DeviceHostError({
193+ hostId: config.id,
194+ step: "reserving hub port",
195+ cause,
196+ }),
197+ ),
198+ );
199+ const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
200+ Effect.mapError(
201+ (cause) =>
202+ new DeviceHost.DeviceHostError({
203+ hostId: config.id,
204+ step: "reserving daemon port",
205+ cause,
206+ }),
207+ ),
208+ );
209+ const scope = yield* Scope.make();
210+ connectionScope = scope;
211+ const child = yield* spawner
212+ .spawn(
213+ ChildProcess.make(
214+ ssh,
215+ [
216+ ...baseSshArgs(targetFor(config), { batchMode: "yes" }),
217+ ...identityArgs(config),
218+ "-o",
219+ "ExitOnForwardFailure=yes",
220+ "-o",
221+ "ServerAliveInterval=10",
222+ "-o",
223+ "ServerAliveCountMax=3",
224+ "-N",
225+ "-L",
226+ `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`,
227+ ...(remote.daemonPort === undefined
228+ ? []
229+ : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]),
230+ config.target,
231+ ],
232+ { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
233+ ),
234+ )
235+ .pipe(
236+ Effect.provideService(Scope.Scope, scope),
237+ Effect.mapError(
238+ (cause) =>
239+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }),
240+ ),
241+ );
242+ let stderr = "";
243+ yield* child.stderr.pipe(
244+ Stream.decodeText(),
245+ Stream.runForEach((chunk) =>
246+ Effect.sync(() => {
247+ stderr = (stderr + chunk).slice(-2000);
248+ }),
249+ ),
250+ Effect.forkIn(scope),
251+ );
252+ const next = {
253+ nodePath: remote.nodePath,
254+ hub: { origin: `http://127.0.0.1:${hubPort}` },
255+ ...(remote.daemonPort !== undefined &&
256+ remote.token !== undefined &&
257+ remote.entryPath !== undefined
258+ ? {
259+ agentDevice: {
260+ baseUrl: `http://127.0.0.1:${daemonPort}`,
261+ token: remote.token,
262+ entryPath: remote.entryPath,
263+ },
264+ }
265+ : {}),
266+ helpers: remote.helpers,
267+ run,
268+ };
269+ for (const [baseUrl, route] of [
270+ [next.hub.origin, "/readyz"],
271+ ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []),
272+ ]) {
273+ yield* waitForHttpReady({
274+ baseUrl: baseUrl!,
275+ path: route!,
276+ timeoutMs: 15000,
277+ makeError: () =>
278+ new DeviceHost.DeviceHostError({
279+ hostId: config.id,
280+ step: "waiting for SSH forward",
281+ cause: new Error(stderr || "Forwarded endpoint did not answer."),
282+ }),
283+ }).pipe(Effect.provideService(HttpClient.HttpClient, http));
284+ }
285+ if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice });
286+ ready = next;
287+ yield* onStatus("ready");
288+ // Reconnect also repairs helpers that died while SSH itself stayed connected.
289+ const unhealthy = Effect.gen(function* () {
290+ while (true) {
291+ yield* Effect.sleep("10 seconds");
292+ const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe(
293+ Effect.timeout("5 seconds"),
294+ Effect.map((r) => r.status === 200),
295+ Effect.orElseSucceed(() => false),
296+ );
297+ const daemonAlive = next.agentDevice
298+ ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe(
299+ Effect.timeout("5 seconds"),
300+ Effect.map((r) => r.status === 200),
301+ Effect.orElseSucceed(() => false),
302+ )
303+ : true;
304+ if (!alive || !daemonAlive) return;
305+ }
306+ });
307+ yield* Effect.gen(function* () {
308+ yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy);
309+ if (stopped || connectionScope !== scope) return;
310+ ready = null;
311+ yield* onStatus("starting", "Reconnecting to device host…");
312+ yield* Scope.close(scope, Exit.void);
313+ let delay = 1000;
314+ while (true) {
315+ if (stopped || connectionScope !== scope) return;
316+ yield* Effect.sleep(delay);
317+ const result = yield* lock
318+ .withPermit(
319+ Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))),
320+ )
321+ .pipe(Effect.result);
322+ if (result._tag === "Success") return;
323+ yield* onStatus("failed", result.failure.message);
324+ if (connectionScope && connectionScope !== scope)
325+ yield* Scope.close(connectionScope, Exit.void);
326+ connectionScope = scope;
327+ delay = Math.min(delay * 2, 30000);
328+ }
329+ }).pipe(Effect.forkIn(parentScope));
330+ return next;
331+ });
332+
333+ const connect = Effect.fn("SshDeviceHost.connect")(function* () {
334+ for (let attempt = 0; ; attempt++) {
335+ const result = yield* connectOnce().pipe(Effect.result);
336+ if (result._tag === "Success") return result.success;
337+ const failedScope = connectionScope;
338+ connectionScope = null;
339+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
340+ // SSH binds after the reservation is released, so a competing bind needs fresh ports.
341+ if (
342+ attempt >= 2 ||
343+ !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step)
344+ )
345+ return yield* result.failure;
346+ }
347+ });
348+
349+ const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
350+ lock.withPermit(
351+ Effect.gen(function* () {
352+ stopped = false;
353+ if (ready) return ready;
354+ summary = yield* provide(probe(config));
355+ yield* onPhase("installing");
356+ return yield* connect().pipe(
357+ Effect.tapError(() =>
358+ connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,
359+ ),
360+ );
361+ }),
362+ );
363+ const stop = lock.withPermit(
364+ Effect.gen(function* () {
365+ stopped = true;
366+ ready = null;
367+ if (connectionScope) yield* Scope.close(connectionScope, Exit.void);
368+ connectionScope = null;
369+ if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore);
370+ activated = false;
371+ wantsAgent = false;
372+ }),
373+ );
374+ const changeAgent = (enabled: boolean) =>
375+ lock.withPermit(
376+ Effect.gen(function* () {
377+ wantsAgent = enabled;
378+ if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice };
379+ if (!enabled && !ready?.agentDevice) return null;
380+ ready = null;
381+ const previousScope = connectionScope;
382+ connectionScope = null;
383+ if (previousScope) yield* Scope.close(previousScope, Exit.void);
384+ if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent"));
385+ return yield* connect().pipe(
386+ Effect.onError(() =>
387+ Effect.gen(function* () {
388+ const failedScope = connectionScope;
389+ connectionScope = null;
390+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
391+ if (enabled)
392+ yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore);
393+ }),
394+ ),
395+ );
396+ }),
397+ );
398+ yield* Effect.addFinalizer(() => stop);
399+ return {
400+ id: config.id,
401+ summary: Effect.sync(() => summary),
402+ current: Effect.sync(() => ready),
403+ ensureReady,
404+ ensureAgentReady: (onPhase) =>
405+ onPhase("installing").pipe(
406+ Effect.flatMap(() => changeAgent(true)),
407+ Effect.flatMap((value) =>
408+ value?.agentDevice
409+ ? Effect.succeed({ ...value, agentDevice: value.agentDevice })
410+ : Effect.fail(
411+ new DeviceHost.DeviceHostError({
412+ hostId: config.id,
413+ step: "starting agent tools",
414+ cause: new Error("Daemon endpoint missing"),
415+ }),
416+ ),
417+ ),
418+ ),
419+ stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
420+ stop,
421+ platformAvailability: (platform) =>
422+ provide(probe(config)).pipe(
423+ Effect.map((value) => {
424+ summary = { ...summary, platforms: value.platforms };
425+ return value.platforms.find((p) => p.platform === platform)!;
426+ }),
427+ Effect.orElseSucceed(() => ({
428+ platform,
429+ available: false,
430+ reason: "Cannot reach device host. Test its SSH connection in Settings.",
431+ })),
432+ ),
433+ } satisfies DeviceHost.DeviceHost["Service"];
434+});
apps/server/src/device/sshDeviceScript.test.ts+220−0
@@ -0,0 +1,220 @@
1+// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes.
2+import * as Effect from "effect/Effect";
3+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4+import { describe, expect, it } from "@effect/vitest";
5+import * as NodeChildProcess from "node:child_process";
6+import * as NodeFSP from "node:fs/promises";
7+import * as NodeOS from "node:os";
8+import * as NodePath from "node:path";
9+import * as NodeUtil from "node:util";
10+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
11+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
12+
13+const exec = NodeUtil.promisify(NodeChildProcess.execFile);
14+
15+it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
16+ Effect.gen(function* () {
17+ if ((yield* HostProcessPlatform) === "win32") return;
18+ yield* Effect.promise(async () => {
19+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-"));
20+ try {
21+ const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr");
22+ await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true });
23+ await NodeFSP.writeFile(
24+ NodePath.join(javaHome, "bin/java"),
25+ "#!/bin/sh\necho test-java\n",
26+ { mode: 0o755 },
27+ );
28+ const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], {
29+ env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" },
30+ });
31+ expect(result.stdout.trim()).toBe("test-java");
32+ } finally {
33+ await NodeFSP.rm(home, { recursive: true, force: true });
34+ }
35+ });
36+ }),
37+);
38+
39+it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
40+ Effect.gen(function* () {
41+ if ((yield* HostProcessPlatform) === "win32") return;
42+ yield* Effect.promise(async () => {
43+ const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line";
44+ const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]);
45+ expect(result.stdout).toBe(value);
46+ });
47+ }),
48+);
49+
50+describe("remote helper lifecycle", () => {
51+ it.effect("reuses its own healthy helpers and stops only its own runtime", () =>
52+ Effect.gen(function* () {
53+ if ((yield* HostProcessPlatform) === "win32") return;
54+ yield* Effect.promise(async () => {
55+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-"));
56+ const bin = NodePath.join(home, "bin");
57+ await NodeFSP.mkdir(bin);
58+ await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
59+ const root = NodePath.join(home, ".t3/device");
60+ const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`);
61+ const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`);
62+ const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs");
63+ const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs");
64+ await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), {
65+ recursive: true,
66+ });
67+ await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), {
68+ recursive: true,
69+ });
70+ await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION);
71+ await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION);
72+ await NodeFSP.writeFile(
73+ hub,
74+ `import http from 'node:http'; import fs from 'node:fs';
75+if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
76+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');`,
77+ );
78+ await NodeFSP.writeFile(
79+ agent,
80+ `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process';
81+const args=process.argv.slice(2);
82+const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
83+const file=path.join(state,'daemon.json');
84+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 {} }
85+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?.();}); }
86+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(); }
87+`,
88+ );
89+ const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
90+ const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
91+ let invocation = 0;
92+ const invoke = async (
93+ owner: string,
94+ mode: "start" | "agent-start" | "stop-agent" | "stop",
95+ upgraded = false,
96+ ) => {
97+ const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
98+ await NodeFSP.writeFile(
99+ file,
100+ `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` +
101+ remoteDeviceScript(owner, mode)
102+ .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION)
103+ .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION),
104+ );
105+ const result = await exec(process.execPath, [file], {
106+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
107+ });
108+ return result.stdout ? JSON.parse(result.stdout) : null;
109+ };
110+ const template = NodePath.join(home, "hub-template");
111+ await NodeFSP.cp(hubDir, template, { recursive: true });
112+ await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
113+ const installLock = hubDir + ".lock";
114+ await NodeFSP.symlink("2147483647:exited-installer", installLock);
115+ await NodeFSP.writeFile(
116+ NodePath.join(bin, "npm"),
117+ `#!${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});`,
118+ { mode: 0o755 },
119+ );
120+ await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
121+ await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), "");
122+ try {
123+ const [manual, concurrent] = await Promise.all([
124+ invoke("one", "start"),
125+ invoke("one", "start"),
126+ ]);
127+ expect(concurrent.hubPort).toBe(manual.hubPort);
128+ expect(manual.daemonPort).toBeUndefined();
129+ await expect(
130+ NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")),
131+ ).rejects.toThrow();
132+ const [first, concurrentAgent] = await Promise.all([
133+ invoke("one", "agent-start"),
134+ invoke("one", "agent-start"),
135+ ]);
136+ expect(concurrentAgent.hubPort).toBe(first.hubPort);
137+ expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
138+ const second = await invoke("two", "agent-start");
139+ const reused = await invoke("one", "agent-start");
140+ expect(reused.hubPort).toBe(first.hubPort);
141+ expect(reused.daemonPort).toBe(first.daemonPort);
142+ expect(second.hubPort).not.toBe(first.hubPort);
143+ expect(second.daemonPort).not.toBe(first.daemonPort);
144+ const firstHub = JSON.parse(
145+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
146+ );
147+ const secondHub = JSON.parse(
148+ await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"),
149+ );
150+ await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), "");
151+ let repaired = await invoke("one", "agent-start");
152+ expect(repaired.hubPort).not.toBe(first.hubPort);
153+ const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8"))
154+ .trim()
155+ .split("\n");
156+ expect(stopped).toContain(String(firstHub.pid));
157+ expect(stopped).not.toContain(String(secondHub.pid));
158+ const previousDaemon = JSON.parse(
159+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
160+ );
161+ for (const [source, name, version] of [
162+ [hubDir, "expo-device-hub", nextHubVersion],
163+ [agentDir, "agent-device", nextAgentVersion],
164+ ]) {
165+ const destination = NodePath.join(root, `tools/${name}@${version}`);
166+ await NodeFSP.cp(source!, destination, { recursive: true });
167+ await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!);
168+ }
169+ const upgraded = await invoke("one", "agent-start", true);
170+ expect(upgraded.entryPath).toContain(nextAgentVersion);
171+ const upgradedHub = JSON.parse(
172+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
173+ );
174+ expect(upgradedHub.entryPath).toContain(nextHubVersion);
175+ const upgradedDaemon = JSON.parse(
176+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
177+ );
178+ expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid);
179+ expect(await invoke("one", "agent-start", true)).toEqual(upgraded);
180+ await NodeFSP.writeFile(
181+ NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`),
182+ "",
183+ );
184+ repaired = await invoke("one", "agent-start", true);
185+ expect(
186+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
187+ ).toBe(String(upgradedDaemon.pid));
188+ expect(repaired.daemonPort).not.toBe(upgraded.daemonPort);
189+ // Stop still uses the recorded entry when a future pinned package is not installed yet.
190+ const originalScript = remoteDeviceScript("one", "stop-agent");
191+ const upgradedStop = NodePath.join(home, "upgraded-stop.cjs");
192+ await NodeFSP.writeFile(
193+ upgradedStop,
194+ originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"),
195+ );
196+ await exec(process.execPath, [upgradedStop], {
197+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
198+ });
199+ const daemon = JSON.parse(
200+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
201+ );
202+ expect(
203+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
204+ ).toBe(String(daemon.pid));
205+ expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true);
206+ await invoke("one", "stop");
207+ expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true);
208+ expect(
209+ JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"))
210+ .owner,
211+ ).toBe("two");
212+ } finally {
213+ await invoke("one", "stop").catch(() => {});
214+ await invoke("two", "stop").catch(() => {});
215+ await NodeFSP.rm(home, { recursive: true, force: true });
216+ }
217+ });
218+ }),
219+ );
220+});
apps/server/src/device/sshDeviceScript.ts+192−0
@@ -0,0 +1,192 @@
1+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
2+
3+export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
4+
5+/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */
6+export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
7+if [ -z "$ANDROID_HOME" ]; then
8+ if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk";
9+ elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi
10+fi
11+if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi
12+if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then
13+ 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
14+ if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi
15+ done
16+fi
17+if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi
18+`;
19+
20+/** Node runs this on the host. All paths it returns belong to that host. */
21+export const remoteDeviceScript = (
22+ owner: string,
23+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
24+) =>
25+ `
26+const owner = ${JSON.stringify(owner)};
27+const mode = ${JSON.stringify(mode)};
28+const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
29+const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)};
30+` +
31+ String.raw`
32+const fs = require('node:fs');
33+const path = require('node:path');
34+const os = require('node:os');
35+const net = require('node:net');
36+const { spawn, spawnSync } = require('node:child_process');
37+const root = path.join(os.homedir(), '.t3', 'device');
38+const state = path.join(root, 'hosts', owner);
39+const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
40+const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
41+const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
42+const stopHub = hub => {
43+ if (!hub || hub.owner !== owner) return;
44+ const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
45+ if (command.includes(hub.entryPath) && command.includes(String(hub.port))) {
46+ try { process.kill(hub.pid, 'SIGTERM'); } catch {}
47+ }
48+};
49+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
50+const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } };
51+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)); }); });
52+async function acquireLock(lock, complete = () => false) {
53+ const deadline = Date.now() + 600000;
54+ const token = process.pid + ':' + require('node:crypto').randomUUID();
55+ const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
56+ while (true) {
57+ try {
58+ // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner.
59+ fs.symlinkSync(token, lock);
60+ return () => { if (owner() === token) fs.unlinkSync(lock); };
61+ } catch (error) {
62+ if (error.code !== 'EEXIST') throw error;
63+ if (complete()) return null;
64+ const previous = owner();
65+ if (previous === null) continue;
66+ const pid = Number(previous.split(':')[0]);
67+ if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock);
68+ try { process.kill(pid, 0); } catch (error) {
69+ if (error.code === 'ESRCH' && owner() === previous) {
70+ try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
71+ continue;
72+ }
73+ }
74+ if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.');
75+ await sleep(500);
76+ }
77+ }
78+}
79+async function install(name, version, entry) {
80+ const dir = path.join(root, 'tools', name + '@' + version);
81+ const file = path.join(dir, 'node_modules', name, entry);
82+ const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version;
83+ if (complete()) return file;
84+ fs.mkdirSync(path.dirname(dir), { recursive: true });
85+ const lock = dir + '.lock';
86+ const release = await acquireLock(lock, complete);
87+ if (!release) return file;
88+ let staging;
89+ try {
90+ if (complete()) return file;
91+ staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-'));
92+ const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
93+ if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000)));
94+ if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name);
95+ fs.writeFileSync(path.join(staging, '.install-complete'), version);
96+ fs.rmSync(dir, { recursive: true, force: true });
97+ fs.renameSync(staging, dir);
98+ return file;
99+ } finally {
100+ if (staging) fs.rmSync(staging, { recursive: true, force: true });
101+ release();
102+ }
103+}
104+(async () => {
105+ const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0;
106+ const android = run('adb', ['version']).status === 0;
107+ const platforms = [
108+ { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) },
109+ { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) },
110+ ];
111+ if (mode === 'probe') {
112+ if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
113+ if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.');
114+ console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return;
115+ }
116+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
117+ // Serialize starts and stops for this environment/host owner, including agent startup.
118+ const hostLock = path.join(state, 'runtime.lock');
119+ const releaseHost = await acquireLock(hostLock);
120+ try {
121+ const hubFile = path.join(state, 'hub.json');
122+ const daemonFile = path.join(state, 'daemon.json');
123+ const agentFile = path.join(state, 'agent.json');
124+ if (mode === 'stop' || mode === 'stop-agent') {
125+ const hub = read(hubFile);
126+ if (mode === 'stop' && hub && hub.owner === owner) {
127+ stopHub(hub);
128+ fs.rmSync(hubFile, { force: true });
129+ }
130+ const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs');
131+ if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]);
132+ return;
133+ }
134+ if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' '));
135+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
136+ const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs');
137+ let hub = read(hubFile);
138+ if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) {
139+ stopHub(hub);
140+ for (let attempt = 0; attempt < 5; attempt++) {
141+ const hubPort = await port();
142+ const log = fs.openSync(path.join(state, 'hub.log'), 'a');
143+ const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], {
144+ cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
145+ });
146+ try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); }
147+ finally { fs.closeSync(log); }
148+ child.unref();
149+ hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry };
150+ write(hubFile, hub);
151+ const deadline = Date.now() + 30000;
152+ let listening = false;
153+ while (child.exitCode === null && child.signalCode === null) {
154+ if (await healthy(hub.port, '/readyz')) { listening = true; break; }
155+ if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); }
156+ await sleep(200);
157+ }
158+ if (listening) break;
159+ // Port reservation and binding happen in different processes. Retry an early exit with a fresh port.
160+ fs.rmSync(hubFile, { force: true });
161+ if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log'));
162+ }
163+ }
164+ let agentResult = {};
165+ if (mode === 'agent-start') {
166+ const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs');
167+ const previousAgent = read(agentFile)?.entryPath;
168+ let daemon = read(daemonFile);
169+ if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) {
170+ const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]);
171+ if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.');
172+ fs.rmSync(daemonFile, { force: true });
173+ daemon = null;
174+ }
175+ if (!daemon) {
176+ fs.rmSync(daemonFile, { force: true });
177+ 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' };
178+ delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG;
179+ run(process.execPath, [agentEntry, 'devices', '--json'], { env });
180+ daemon = read(daemonFile);
181+ }
182+ if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state);
183+ write(agentFile, { entryPath: agentEntry });
184+ agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry };
185+ }
186+ const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
187+ const optional = file => fs.existsSync(file) ? file : null;
188+ console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult,
189+ helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
190+ } finally { releaseHost(); }
191+})().catch(error => { console.error(error.message); process.exitCode = 1; });
192+`;
apps/server/src/mcp/McpDeviceToolkit.test.ts+1−0
@@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ · 91 unmodified lines
9292 screenshot: () => Effect.succeed({ device, png }),
9393 close: () => Effect.void,
9494 agentCli: Effect.succeed("/cli"),
95+ testHost: () => Effect.die("not used"),
9596 agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
9697 });
9798
apps/server/src/mcp/toolkits/device/handlers.ts+3−0
@@ -134,6 +134,9 @@ const handlers = { · 133 unmodified lines
134134 .filter((session) => session.threadId === scope.threadId)
135135 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
136136 return {
137+ hostStatuses: Object.fromEntries(
138+ Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId),
139+ ),
137140 hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
138141 devices: hostId
139142 ? state.devices.filter((device) => device.hostId === hostId)
apps/server/src/ws.ts+4−0
@@ -2770,6 +2770,10 @@ const makeWsRpcLayer = ( · 2769 unmodified lines
27702770 observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {
27712771 "rpc.aggregate": "device",
27722772 }),
2773+ [WS_METHODS.deviceTestHost]: (input) =>
2774+ observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
2775+ "rpc.aggregate": "device",
2776+ }),
27732777 [WS_METHODS.deviceList]: (_input) =>
27742778 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
27752779 "rpc.aggregate": "device",
apps/web/src/components/device/DeviceHostAvailability.tsx+27−0
@@ -0,0 +1,27 @@
1+import type { DevicePlatformAvailability } from "@t3tools/contracts";
2+import { Check, Minus } from "lucide-react";
3+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
4+
5+export function DeviceHostAvailability({
6+ platforms,
7+}: {
8+ platforms: ReadonlyArray<DevicePlatformAvailability>;
9+}) {
10+ return (
11+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
12+ {platforms.map((platform) => (
13+ <Tooltip key={platform.platform}>
14+ <TooltipTrigger render={<span tabIndex={0} className="inline-flex items-center gap-1" />}>
15+ {platform.available ? <Check className="size-3" /> : <Minus className="size-3" />}
16+ {platform.platform === "ios" ? "iOS" : "Android"}{" "}
17+ {platform.available ? "available" : "unavailable"}
18+ </TooltipTrigger>
19+ <TooltipPopup>
20+ {platform.reason ??
21+ (platform.platform === "ios" ? "iOS available" : "Android available")}
22+ </TooltipPopup>
23+ </Tooltip>
24+ ))}
25+ </div>
26+ );
27+}
apps/web/src/components/settings/DeviceHostsSettings.tsx+334−0
@@ -0,0 +1,334 @@
1+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2+import { AppleIcon, AndroidIcon } from "../Icons";
3+import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
4+import { Spinner } from "../ui/spinner";
5+import type {
6+ DevicePlatformAvailability,
7+ EnvironmentId,
8+ SshDeviceHostConfig,
9+} from "@t3tools/contracts";
10+import * as Cause from "effect/Cause";
11+import { randomUUID } from "../../lib/utils";
12+import { useState } from "react";
13+import { deviceEnvironment, useDeviceState } from "../../state/device";
14+import { serverEnvironment } from "../../state/server";
15+import { useAtomCommand } from "../../state/use-atom-command";
16+import { Button } from "../ui/button";
17+import { Input } from "../ui/input";
18+import { MoreVertical, PlusIcon } from "lucide-react";
19+import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
20+import { SettingsRow } from "./settingsLayout";
21+
22+/** Host names and identity paths belong to the selected environment, never all environments. */
23+export function DeviceHostsSettings(props: {
24+ environmentId: EnvironmentId | null;
25+ hosts: ReadonlyArray<SshDeviceHostConfig>;
26+}) {
27+ const update = useAtomCommand(serverEnvironment.updateSettings);
28+ const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
29+ const { state } = useDeviceState(props.environmentId);
30+ const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
31+ const [busy, setBusy] = useState(false);
32+ const validPort = (port: number | undefined) =>
33+ port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
34+ const [checks, setChecks] = useState<
35+ Record<
36+ string,
37+ { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string }
38+ >
39+ >({});
40+ const setCheck = (id: string, value: (typeof checks)[string]) =>
41+ setChecks((current) => ({ ...current, [id]: value }));
42+ const save = async (hosts: ReadonlyArray<SshDeviceHostConfig>) => {
43+ if (!props.environmentId) return;
44+ setBusy(true);
45+ try {
46+ const saved = await update({
47+ environmentId: props.environmentId,
48+ input: { patch: { deviceHosts: hosts } },
49+ });
50+ if (saved._tag === "Success") {
51+ setEditing(null);
52+ }
53+ } finally {
54+ setBusy(false);
55+ }
56+ };
57+ const testConnection = async (host: SshDeviceHostConfig) => {
58+ if (!props.environmentId || checks[host.id]?.pending) return;
59+ setCheck(host.id, { pending: true });
60+ try {
61+ const summary = await test({ environmentId: props.environmentId, input: host });
62+ setCheck(
63+ host.id,
64+ summary._tag === "Failure"
65+ ? { error: Cause.pretty(summary.cause) }
66+ : { platforms: summary.value.platforms },
67+ );
68+ } catch (error) {
69+ setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
70+ }
71+ };
72+ return (
73+ <SettingsRow
74+ id="device-hosts"
75+ title="Device hosts"
76+ description="Add remote machines with simulator or emulator runtimes installed, and this environment will connect over SSH and set up device tools automatically."
77+ control={
78+ <Button
79+ size="sm"
80+ variant="outline"
81+ disabled={busy || !props.environmentId || editing !== null}
82+ onClick={() => {
83+ setEditing({ id: randomUUID(), label: "", target: "" });
84+ }}
85+ >
86+ <PlusIcon className="size-3.5" /> Add host
87+ </Button>
88+ }
89+ >
90+ <div className="pt-3 pb-2">
91+ {!props.environmentId ? (
92+ <p className="text-sm text-muted-foreground">
93+ Select one connected environment to manage its device hosts.
94+ </p>
95+ ) : (
96+ <>
97+ {props.hosts.map((host) => {
98+ const status = state.hostStatuses[host.id];
99+ const check = checks[host.id];
100+ const platforms =
101+ check?.platforms ??
102+ state.hosts.find((value) => value.id === host.id)?.platforms ??
103+ [];
104+ const progress = check?.pending
105+ ? "Checking connection…"
106+ : status?.status === "installing"
107+ ? "Installing device support…"
108+ : status?.status === "starting"
109+ ? "Connecting…"
110+ : null;
111+ const error =
112+ check?.error ?? (status?.status === "failed" ? status.detail : undefined);
113+ return (
114+ <div
115+ key={host.id}
116+ className="flex items-center gap-2 border-t border-border/50 py-2.5"
117+ >
118+ <div className="min-w-0 flex-1">
119+ <div className="flex min-w-0 items-center gap-2">
120+ <p className="truncate text-sm font-medium">{host.label}</p>
121+ {platforms
122+ .filter((platform) => platform.available)
123+ .map((platform) => (
124+ <Tooltip key={platform.platform}>
125+ <TooltipTrigger
126+ render={
127+ <span
128+ tabIndex={0}
129+ role="img"
130+ aria-label={
131+ platform.platform === "ios"
132+ ? "iOS available"
133+ : "Android available"
134+ }
135+ className="shrink-0 text-muted-foreground"
136+ />
137+ }
138+ >
139+ {platform.platform === "ios" ? (
140+ <AppleIcon className="size-3.5" />
141+ ) : (
142+ <AndroidIcon className="size-3.5" />
143+ )}
144+ </TooltipTrigger>
145+ <TooltipPopup>
146+ {platform.platform === "ios" ? "iOS available" : "Android available"}
147+ </TooltipPopup>
148+ </Tooltip>
149+ ))}
150+ </div>
151+ <p className="truncate text-xs text-muted-foreground">{host.target}</p>
152+ {error ? (
153+ <div className="mt-1" role="status">
154+ <details className="text-xs text-destructive">
155+ <summary>Connection failed</summary>
156+ <p className="mt-1 whitespace-pre-wrap break-words">{error}</p>
157+ </details>
158+ </div>
159+ ) : null}
160+ </div>
161+ {progress ? (
162+ <span
163+ role="status"
164+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
165+ >
166+ <Spinner className="size-3" />
167+ {progress}
168+ </span>
169+ ) : null}
170+ <Menu>
171+ <MenuTrigger
172+ render={
173+ <Button
174+ size="icon-sm"
175+ variant="ghost-muted"
176+ disabled={busy}
177+ aria-label={host.label + " options"}
178+ />
179+ }
180+ >
181+ <MoreVertical />
182+ </MenuTrigger>
183+ <MenuPopup align="end">
184+ <MenuItem
185+ onClick={() => {
186+ setEditing(host);
187+ }}
188+ >
189+ Edit
190+ </MenuItem>
191+ <MenuItem
192+ variant="destructive"
193+ onClick={() =>
194+ void save(props.hosts.filter((value) => value.id !== host.id))
195+ }
196+ >
197+ Remove
198+ </MenuItem>
199+ </MenuPopup>
200+ </Menu>
201+ <Button
202+ size="sm"
203+ variant="outline"
204+ disabled={busy || progress !== null}
205+ onClick={() => void testConnection(host)}
206+ >
207+ Test connection
208+ </Button>
209+ </div>
210+ );
211+ })}
212+ {editing ? (
213+ <form
214+ className="space-y-3 border-t border-border/50 py-3"
215+ onSubmit={(event) => {
216+ event.preventDefault();
217+ void save([...props.hosts.filter((host) => host.id !== editing.id), editing]);
218+ }}
219+ >
220+ <label className="block space-y-1 text-sm">
221+ <span>Name</span>
222+ <Input
223+ required
224+ value={editing.label}
225+ disabled={busy}
226+ onChange={(event) => setEditing({ ...editing, label: event.target.value })}
227+ placeholder="Mac mini"
228+ />
229+ </label>
230+ <label className="block space-y-1 text-sm">
231+ <span>SSH target</span>
232+ <Input
233+ required
234+ value={editing.target}
235+ disabled={busy}
236+ onChange={(event) => setEditing({ ...editing, target: event.target.value })}
237+ placeholder="user@host or SSH alias"
238+ />
239+ </label>
240+ <label className="block space-y-1 text-sm">
241+ <span>Identity file, optional</span>
242+ <Input
243+ value={editing.identityFile ?? ""}
244+ disabled={busy}
245+ onChange={(event) => {
246+ const { identityFile: _, ...rest } = editing;
247+ setEditing(
248+ event.target.value ? { ...rest, identityFile: event.target.value } : rest,
249+ );
250+ }}
251+ placeholder="~/.ssh/id_ed25519"
252+ />
253+ </label>
254+ <label className="block space-y-1 text-sm">
255+ <span>Port, optional</span>
256+ <Input
257+ type="number"
258+ min={1}
259+ max={65535}
260+ value={editing.port ?? ""}
261+ disabled={busy}
262+ onChange={(event) => {
263+ const { port: _, ...rest } = editing;
264+ setEditing(
265+ event.target.value ? { ...rest, port: Number(event.target.value) } : rest,
266+ );
267+ }}
268+ placeholder="SSH config default"
269+ />
270+ </label>
271+ <div className="flex flex-wrap gap-2">
272+ <Button
273+ size="sm"
274+ type="submit"
275+ disabled={
276+ busy ||
277+ !editing.label.trim() ||
278+ !editing.target.trim() ||
279+ !validPort(editing.port)
280+ }
281+ >
282+ Save host
283+ </Button>
284+ <Button
285+ size="sm"
286+ type="button"
287+ variant="outline"
288+ disabled={
289+ busy ||
290+ !editing.label.trim() ||
291+ !editing.target.trim() ||
292+ !validPort(editing.port)
293+ }
294+ onClick={() => void testConnection(editing)}
295+ >
296+ Test connection
297+ </Button>
298+ <Button
299+ size="sm"
300+ type="button"
301+ variant="ghost"
302+ disabled={busy}
303+ onClick={() => {
304+ setEditing(null);
305+ }}
306+ >
307+ Cancel
308+ </Button>
309+ </div>
310+ {checks[editing.id]?.pending ? (
311+ <span
312+ role="status"
313+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
314+ >
315+ <Spinner className="size-3" />
316+ Checking connection…
317+ </span>
318+ ) : null}
319+ {checks[editing.id]?.platforms ? (
320+ <DeviceHostAvailability platforms={checks[editing.id]?.platforms ?? []} />
321+ ) : null}
322+ {checks[editing.id]?.error ? (
323+ <p role="alert" className="text-xs text-destructive">
324+ {checks[editing.id]?.error}
325+ </p>
326+ ) : null}
327+ </form>
328+ ) : null}
329+ </>
330+ )}
331+ </div>
332+ </SettingsRow>
333+ );
334+}
apps/web/src/components/settings/IntegrationsSettings.tsx+66−6
@@ -1,3 +1,4 @@
1+import { DeviceHostsSettings } from "./DeviceHostsSettings";
12 /**
23 * Integrations settings - preferences for surfaces T3 Code embeds rather than
34 * owns. Browser is the first section: the defaults a preview tab opens at,
@@ -12,6 +13,7 @@ import { · 8 unmodified lines
1213 type BrowserLinkTarget,
1314 type BrowserProfile,
1415 type EnvironmentId,
16+ type SshDeviceHostConfig,
1517 BROWSER_PROFILE_NAME_MAX_LENGTH,
1618 BROWSER_RECORDING_FRAME_RATES,
1719 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
@@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() { · 566 unmodified lines
584586
585587 function DeviceIntegrationSettings() {
586588 const primaryEnvironment = usePrimaryEnvironment();
587− const environmentId = primaryEnvironment?.environmentId ?? null;
589+ const { environments } = useEnvironments();
590+ const [selectedId, setSelectedId] = useState<string | null>(null);
591+ const selected =
592+ environments.find((environment) => environment.environmentId === selectedId) ??
593+ environments.find(
594+ (environment) => environment.environmentId === primaryEnvironment?.environmentId,
595+ ) ??
596+ environments[0];
597+ const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
598+ const environmentId = connected ? selected.environmentId : null;
599+
600+ return (
601+ <SettingsSection id="devices" title="Devices">
602+ {environments.length > 1 ? (
603+ <SettingsRow
604+ title="Environment"
605+ description="Device support and hosts are shared by all projects in this environment."
606+ control={
607+ <Select
608+ value={selected?.environmentId ?? ""}
609+ onValueChange={(value) => setSelectedId(value)}
610+ >
611+ <SelectTrigger size="sm" aria-label="Device environment">
612+ <SelectValue>{selected?.label ?? "Select environment"}</SelectValue>
613+ </SelectTrigger>
614+ <SelectPopup align="end" alignItemWithTrigger={false}>
615+ {environments.map((environment) => (
616+ <SelectItem key={environment.environmentId} value={environment.environmentId}>
617+ {environment.label}
618+ {environment.connection.phase === "connected" ? "" : " · Offline"}
619+ </SelectItem>
620+ ))}
621+ </SelectPopup>
622+ </Select>
623+ }
624+ />
625+ ) : null}
626+ <DeviceIntegrationControls
627+ key={selected?.environmentId ?? "none"}
628+ environmentId={environmentId}
629+ hosts={selected?.serverConfig?.settings.deviceHosts ?? []}
630+ enabled={selected?.serverConfig?.settings.enableDeviceSupport ?? false}
631+ agentAccessEnabled={selected?.serverConfig?.settings.enableAgentDeviceAccess ?? false}
632+ />
633+ </SettingsSection>
634+ );
635+}
636+
637+function DeviceIntegrationControls({
638+ environmentId,
639+ hosts,
640+ enabled,
641+ agentAccessEnabled,
642+}: {
643+ environmentId: EnvironmentId | null;
644+ hosts: ReadonlyArray<SshDeviceHostConfig>;
645+ enabled: boolean;
646+ agentAccessEnabled: boolean;
647+}) {
588648 const { state, loaded } = useDeviceState(environmentId);
589649 const configure = useAtomCommand(deviceEnvironment.configure);
590650 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
591651 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
592− const enabled = state.hostStatus !== "disabled";
593652 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
594653 const [platformsRevealed, setPlatformsRevealed] = useState(false);
595654 // Keep diagnostics visible through subsequent agent setup and refresh phases.
596655 if (platformsRevealed && !enabled) setPlatformsRevealed(false);
597− if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
656+ if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
598657 setPlatformsRevealed(true);
599658 }
600659
@@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { · 14 unmodified lines
615674 };
616675
617676 return (
618− <SettingsSection id="devices" title="Devices">
677+ <>
619678 <SettingsRow
620679 {...searchableSetting("device-hub")}
621680 description={deviceHubDescription}
@@ -674,7 +733,7 @@ function DeviceIntegrationSettings() { · 52 unmodified lines
674733 <>
675734 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
676735 <Switch
677− checked={state.agentAccessEnabled}
736+ checked={agentAccessEnabled}
678737 disabled={!loaded || !environmentId || !enabled || busy || pending !== null}
679738 aria-label="Agent device access"
680739 onCheckedChange={(checked) =>
@@ -689,7 +748,8 @@ function DeviceIntegrationSettings() { · 8 unmodified lines
689748 {state.hostStatusDetail}
690749 </p>
691750 ) : null}
692− </SettingsSection>
751+ <DeviceHostsSettings environmentId={environmentId} hosts={hosts} />
752+ </>
693753 );
694754 }
695755
apps/web/src/components/settings/settingsSearch.ts+6−0
@@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [ · 401 unmodified lines
402402 to: "/settings/projects",
403403 searchTerms: ["allow open drive preview tools sessions"],
404404 },
405+ {
406+ id: "device-hosts",
407+ title: "Device hosts",
408+ to: "/settings/integrations",
409+ searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"],
410+ },
405411 {
406412 id: "agent-device-access",
407413 title: "Agent device access",
docs/internals/devices.md+5−7
@@ -3,8 +3,7 @@ · 2 unmodified lines
33 The environment server owns simulators and emulators the way it owns
44 terminals: discovery, streaming, and agent access all run there, and every
55 client reaches them through the environment connection. This is what makes the
6−Device panel work over Tailscale and T3 Connect, and what will let a device
7−host on another machine slot in later.
6+Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices.
87
98 ## Two external tools, one seam
109
@@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down. · 10 unmodified lines
2120 Everything platform-specific sits behind
2221 [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the
2322 proxy, and the MCP tools only see a hub origin and an agent-device endpoint.
24−An SSH or cloud host would forward those two things to the server and change
25−nothing above it.
23+SSH hosts forward both endpoints to server loopback. Every proxied request
24+also carries the host id; device ids alone are not unique across hosts.
2625
2726 ## The hub is never exposed
2827
@@ -57,9 +56,8 @@ screenshot capture and stream tuning. · 28 unmodified lines
5756 The `device_*` toolkit is deliberately four tools: list, open, screenshot, and
5857 close. Driving happens through the `agent-device` CLI, which has the semantic
5958 snapshot model agents need and stays current with its own releases. T3 prepends
60−a shim directory to the provider's PATH and sets
61−`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the
62−agent never handles the endpoint or token.
59+a shim directory to the provider's PATH. The CLI installs on the environment
60+server even when that server cannot run simulators. Hosts start on demand.
6361
6462 That environment is fixed when the provider subprocess spawns, so
6563 [`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)
docs/user/devices.md+28−1
@@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it. · 14 unmodified lines
1515
1616 Choose a running device to watch it, or choose **Start** next to a stopped
1717 device to boot it. The panel shows when you or an agent starts a device.
18+Each device opens in its own tab. Use **+ → Device** to open another, and
19+double-click a tab name or choose **Rename** from its context menu to rename it.
20+Only the visible tab streams video; switching tabs keeps both devices running.
1821 Turn off the device hub in **Settings → Integrations → Devices** to stop the
1922 helper processes; simulators and emulators keep running until you power them
2023 off.
@@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices. · 8 unmodified lines
2932 The screen is interactive: click and drag to touch, type while the screen is
3033 focused, and use the toolbar for Home, Back, and Recents on Android, rotate on
3134 iOS, and power off. Close the tab to stop watching; the device keeps running
32−unless you power it off.
35+unless you power it off. Closed tabs stay closed after a reload. To watch the
36+device again, choose it from **+ → Device**.
3337
3438 ## Tools
3539
@@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the · 25 unmodified lines
6165 local network, Tailscale, and T3 Connect. Live video needs a secure page
6266 (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower
6367 still-image stream and Android cannot show video.
68+
69+## SSH device hosts
70+
71+In Settings → Integrations → Devices, select one connected environment
72+and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with
73+an optional identity file and port. These resolve on the environment server,
74+so use the SSH configuration and keys available there. Password prompts are
75+not supported.
76+
77+**Test connection** checks SSH, Node, npm, and platform tools without installing
78+anything. The first device listing installs pinned device tools on the host.
79+Node 22 or newer and npm must be available to non-interactive SSH commands.
80+T3 checks common Homebrew and Android SDK locations; custom installations need
81+the appropriate PATH and ANDROID_HOME on the host.
82+
83+The picker identifies devices by host when several hosts are configured.
84+Connections recover after interruptions. Removing a host closes its device
85+sessions and stops its T3 helpers when reachable; simulators keep running.
86+
87+T3 provides discovery, streaming, and control. Arrange app builds,
88+installation, and connectivity to development servers such as Metro separately.
89+A simulator on another machine cannot reach Metro through your environment's
90+localhost without forwarding or another reachable address.
packages/client-runtime/src/state/device.ts+4−0
@@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms<R, E>( · 27 unmodified lines
2828 scheduler,
2929 concurrency,
3030 }),
31+ testHost: createEnvironmentRpcCommand(runtime, {
32+ label: "environment-data:device:test-host",
33+ tag: WS_METHODS.deviceTestHost,
34+ }),
3135 list: createEnvironmentRpcCommand(runtime, {
3236 label: "environment-data:device:list",
3337 tag: WS_METHODS.deviceList,
packages/contracts/src/device.ts+23−1
@@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type; · 24 unmodified lines
2525 /** The server machine. Always present; other host kinds are future work. */
2626 export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
2727
28+/** SSH aliases and key paths are resolved on the environment server. */
29+export const SshDeviceHostConfig = Schema.Struct({
30+ id: DeviceHostId.check(
31+ Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
32+ Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."),
33+ ),
34+ label: TrimmedNonEmptyString,
35+ target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)),
36+ identityFile: Schema.optional(TrimmedNonEmptyString),
37+ port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
38+});
39+export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
40+
41+export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(
42+ Schema.makeFilter(
43+ (hosts) =>
44+ new Set(hosts.map((host) => host.id)).size === hosts.length ||
45+ "Device host ids must be unique.",
46+ ),
47+);
48+
2849 /** Simulator udid or adb serial (an AVD name while it is not running). */
2950 export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
3051 export type DeviceId = typeof DeviceId.Type;
@@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; · 24 unmodified lines
5576
5677 export const DeviceHostSummary = Schema.Struct({
5778 id: DeviceHostId,
58− kind: Schema.Literals(["local"]),
79+ kind: Schema.Literals(["local", "ssh"]),
5980 label: TrimmedNonEmptyString,
6081 platforms: Schema.Array(DevicePlatformAvailability),
6182 hubInstalled: Schema.Boolean,
@@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type; · 362 unmodified lines
424445 // panel describe devices the same way.
425446
426447 export const DeviceToolListResult = Schema.Struct({
448+ hostStatuses: DeviceServiceState.fields.hostStatuses,
427449 hosts: Schema.Array(DeviceHostSummary),
428450 devices: Schema.Array(DeviceSummary),
429451 /** Devices already open in this thread's Device panel. */
packages/contracts/src/rpc.ts+10−0
@@ -189,6 +189,8 @@ import { · 188 unmodified lines
189189 DeviceDetailInput,
190190 DeviceError,
191191 DeviceListInput,
192+ SshDeviceHostConfig,
193+ DeviceHostSummary,
192194 DeviceOpenInput,
193195 DeviceServiceState,
194196 DeviceSession,
@@ -328,6 +330,7 @@ export const WS_METHODS = { · 133 unmodified lines
328330 // Device methods
329331 deviceConfigure: "device.configure",
330332 deviceList: "device.list",
333+ deviceTestHost: "device.testHost",
331334 deviceOpen: "device.open",
332335 deviceClose: "device.close",
333336 deviceShutdown: "device.shutdown",
@@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov · 767 unmodified lines
11011104 stream: true,
11021105 });
11031106
1107+const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, {
1108+ payload: SshDeviceHostConfig,
1109+ success: DeviceHostSummary,
1110+ error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1111+});
1112+
11041113 const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {
11051114 payload: DeviceListInput,
11061115 success: DeviceServiceState,
@@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make( · 273 unmodified lines
13801389 WsSubscribeDiscoveredLocalServersRpc,
13811390 WsDeviceConfigureRpc,
13821391 WsDeviceListRpc,
1392+ WsDeviceTestHostRpc,
13831393 WsDeviceOpenRpc,
13841394 WsDeviceCloseRpc,
13851395 WsDeviceShutdownRpc,
packages/contracts/src/settings.test.ts+13−0
@@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => { · 758 unmodified lines
759759 expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
760760 });
761761 });
762+
763+const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
764+
765+it("validates remote device hosts and rejects ambiguous host ids", () => {
766+ const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 };
767+ expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]);
768+ expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow();
769+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow();
770+ expect(() =>
771+ decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }),
772+ ).toThrow();
773+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
774+});
packages/contracts/src/settings.ts+3−0
@@ -1,3 +1,4 @@
1+import { SshDeviceHostConfigs } from "./device.ts";
12 import * as Effect from "effect/Effect";
23 import * as Duration from "effect/Duration";
34 import * as Schema from "effect/Schema";
@@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({ · 979 unmodified lines
983984 enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
984985 /** Whether the server-local Device panel setup flow has been completed. */
985986 deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
987+ deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
986988 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
987989 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
988990 ),
@@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 269 unmodified lines
12581260 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
12591261 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
12601262 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
1263+ deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
12611264 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
12621265 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
12631266 backgroundActivity: Schema.optionalKey(
packages/shared/src/serverSettings.test.ts+10−0
@@ -21,6 +21,16 @@ import { · 20 unmodified lines
2121 } from "./serverSettings.ts";
2222
2323 describe("serverSettings helpers", () => {
24+ it("replaces SSH host lists when saving, editing, and removing hosts", () => {
25+ const host = { id: "mini", label: "Mac mini", target: "mini" };
26+ const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
27+ expect(saved.deviceHosts).toEqual([host]);
28+ const replacement = { ...host, target: "other-mini" };
29+ const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] });
30+ expect(edited.deviceHosts).toEqual([replacement]);
31+ expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]);
32+ });
33+
2434 it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
2535 const project = { id: ProjectId.make("project-actions"), scripts: [] };
2636 const action = {
pnpm-lock.yaml+3−0
@@ -540,6 +540,9 @@ importers: · 539 unmodified lines
540540 '@t3tools/shared':
541541 specifier: workspace:*
542542 version: link:../../packages/shared
543+ '@t3tools/ssh':
544+ specifier: workspace:*
545+ version: link:../../packages/ssh
543546 '@t3tools/tailscale':
544547 specifier: workspace:*
545548 version: link:../../packages/tailscale