authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 11:59:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 11:59:34-07:00
logd2eeacd8ccde4d8763ca315b4cddf2964db93329
tree71894c7b2f8f7624592ca34f32bc11c1e1d9bf0f
parent7734c6d71b6dd5df9c9b4856cff8e419b8837fe8
signature Signed by PGP key B5690EEEBB952194

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


30 files changed, 1794 insertions(+), 78 deletions(-)

apps/server/package.json+1
...@@ -40,6 +40,7 @@...@@ -40,6 +40,7 @@
40 "@effect/vitest": "catalog:",40 "@effect/vitest": "catalog:",
41 "@t3tools/contracts": "workspace:*",41 "@t3tools/contracts": "workspace:*",
42 "@t3tools/shared": "workspace:*",42 "@t3tools/shared": "workspace:*",
43 "@t3tools/ssh": "workspace:*",
43 "@t3tools/tailscale": "workspace:*",44 "@t3tools/tailscale": "workspace:*",
44 "@t3tools/web": "workspace:*",45 "@t3tools/web": "workspace:*",
45 "@types/bun": "1.3.14",46 "@types/bun": "1.3.14",
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = {
145 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,145 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
146 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,146 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
147 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,147 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
148 [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
148 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,149 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,
149 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,150 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
150 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,151 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
apps/server/src/device/DeviceActions.test.ts+1
...@@ -16,6 +16,7 @@ const makeReady = (...@@ -16,6 +16,7 @@ const makeReady = (
16) => {16) => {
17 const calls: Call[] = [];17 const calls: Call[] = [];
18 const ready: DeviceHostReady = {18 const ready: DeviceHostReady = {
19 nodePath: process.execPath,
19 hub: { origin: "http://127.0.0.1:1" },20 hub: { origin: "http://127.0.0.1:1" },
20 helpers,21 helpers,
21 run: (command, args, options) => {22 run: (command, args, options) => {
apps/server/src/device/DeviceActions.ts+1-1
...@@ -327,7 +327,7 @@ const serveSimPermissions = (...@@ -327,7 +327,7 @@ const serveSimPermissions = (
327 reason: "helper_missing",327 reason: "helper_missing",
328 });328 });
329 yield* ready329 yield* ready
330 .run(process.execPath, [330 .run(ready.nodePath, [
331 cli,331 cli,
332 "permissions",332 "permissions",
333 input.decision,333 input.decision,
apps/server/src/device/DeviceHost.ts+2-1
...@@ -46,11 +46,12 @@ export interface DeviceHubEndpoint {...@@ -46,11 +46,12 @@ export interface DeviceHubEndpoint {
46export interface AgentDeviceEndpoint {46export interface AgentDeviceEndpoint {
47 readonly baseUrl: string;47 readonly baseUrl: string;
48 readonly token: string;48 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. */
50 readonly entryPath: string;50 readonly entryPath: string;
51}51}
5252
53export interface DeviceHostReady {53export interface DeviceHostReady {
54 readonly nodePath: string;
54 readonly hub: DeviceHubEndpoint;55 readonly hub: DeviceHubEndpoint;
55 /**56 /**
56 * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub)57 * 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 @@...@@ -1,5 +1,7 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import { HttpClient, HttpClientResponse } from "effect/unstable/http";6import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5import { ServerSettingsService } from "../serverSettings.ts";7import { ServerSettingsService } from "../serverSettings.ts";
...@@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",...@@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",
10 Effect.gen(function* () {12 Effect.gen(function* () {
11 const host = (id: string, failed = false): DeviceHost["Service"] => {13 const host = (id: string, failed = false): DeviceHost["Service"] => {
12 const ready = {14 const ready = {
15 nodePath: process.execPath,
13 hub: { origin: `http://${id}` },16 hub: { origin: `http://${id}` },
14 agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },17 agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
15 run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),18 run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
...@@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails",...@@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails",
20 summary: Effect.succeed({23 summary: Effect.succeed({
21 id,24 id,
22 label: id,25 label: id,
23 kind: "local",26 kind: id === "b" ? "ssh" : "local",
24 hubInstalled: true,27 hubInstalled: true,
25 agentDeviceInstalled: true,28 agentDeviceInstalled: true,
26 platforms: [{ platform: "android", available: true }],29 platforms: id === "b" ? [] : [{ platform: "android", available: true }],
27 }),30 }),
28 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),31 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
29 ensureReady: () =>32 ensureReady: () =>
...@@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails",...@@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails",
59 ),62 ),
60 );63 );
61 const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));64 const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
62 const service = yield* makeWithHosts(hosts).pipe(65 const writeStarted = yield* Deferred.make<void>();
63 Effect.provideService(HttpClient.HttpClient, http),66 const finishWrite = yield* Deferred.make<void>();
64 );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();
65 const listed = yield* service.list;78 const listed = yield* service.list;
66 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);79 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
67 expect(listed.hostStatuses.offline?.status).toBe("failed");80 expect(listed.hostStatuses.offline?.status).toBe("failed");
...@@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails",...@@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails",
74 expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);87 expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
75 expect(state.hostStatuses.a?.status).toBe("ready");88 expect(state.hostStatuses.a?.status).toBe("ready");
76 expect(state.hostStatuses.offline?.status).toBe("failed");89 expect(state.hostStatuses.offline?.status).toBe("failed");
77 yield* service.agentReadinessIfSupported("b");90 const targeting = yield* service
78 expect((yield* service.state).hostStatuses.b?.status).toBe("ready");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");
79 yield* service.configure({ enabled: false });119 yield* service.configure({ enabled: false });
80 expect((yield* service.state).hostStatuses).toEqual({});120 expect((yield* service.state).hostStatuses).toEqual({});
81 }).pipe(121 }).pipe(
apps/server/src/device/DeviceService.test.ts+1
...@@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* (
70 let booted = false;70 let booted = false;
71 let shutDown = false;71 let shutDown = false;
72 const ready: DeviceHost.DeviceHostReady = {72 const ready: DeviceHost.DeviceHostReady = {
73 nodePath: process.execPath,
73 hub: { origin: "http://device.test" },74 hub: { origin: "http://device.test" },
74 helpers: { serveSimAxSettings: null, serveSimCli: null },75 helpers: { serveSimAxSettings: null, serveSimCli: null },
75 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),76 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
apps/server/src/device/DeviceService.ts+220-52
...@@ -30,6 +30,8 @@ import {...@@ -30,6 +30,8 @@ import {
30 type DeviceSession,30 type DeviceSession,
31 type DeviceShutdownInput,31 type DeviceShutdownInput,
32 type DeviceSummary,32 type DeviceSummary,
33 type SshDeviceHostConfig,
34 type DeviceHostSummary,
33 LOCAL_DEVICE_HOST_ID,35 LOCAL_DEVICE_HOST_ID,
34 type ThreadId,36 type ThreadId,
35} from "@t3tools/contracts";37} from "@t3tools/contracts";
...@@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts";
60import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";62import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
61import * as ProcessRunner from "../processRunner.ts";63import * as ProcessRunner from "../processRunner.ts";
62import * as DeviceHost from "./DeviceHost.ts";64import * as DeviceHost from "./DeviceHost.ts";
65import * as SshDeviceHost from "./SshDeviceHost.ts";
66import * as Exit from "effect/Exit";
63import * as LocalDeviceHost from "./LocalDeviceHost.ts";67import * as LocalDeviceHost from "./LocalDeviceHost.ts";
6468
65/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */69/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
...@@ -105,6 +109,9 @@ export class DeviceService extends Context.Service<...@@ -105,6 +109,9 @@ export class DeviceService extends Context.Service<
105 DeviceService,109 DeviceService,
106 {110 {
107 readonly agentCli: Effect.Effect<string, DeviceError>;111 readonly agentCli: Effect.Effect<string, DeviceError>;
112 readonly testHost: (
113 config: SshDeviceHostConfig,
114 ) => Effect.Effect<DeviceHostSummary, DeviceError>;
108 readonly agentTarget: (input: {115 readonly agentTarget: (input: {
109 threadId: ThreadId;116 threadId: ThreadId;
110 hostId: DeviceHostId;117 hostId: DeviceHostId;
...@@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) =>...@@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) =>
154161
155export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (162export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
156 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,163 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 ),
157 configureAgent: (171 configureAgent: (
158 hostId: DeviceHostId,172 hostId: DeviceHostId,
159 ready: DeviceHost.DeviceHostAgentReady,173 ready: DeviceHost.DeviceHostAgentReady,
...@@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
183 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);197 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
184 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();198 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
185 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);199 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
200 let publishedHosts = new Map(hosts);
186 const stateRef = yield* SynchronizedRef.make<ServiceState>({201 const stateRef = yield* SynchronizedRef.make<ServiceState>({
187 state: {202 state: {
188 hosts: initialHosts,203 hosts: initialHosts,
...@@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
217 hostId: DeviceHostId,232 hostId: DeviceHostId,
218 status: DeviceServiceState["hostStatuses"][string],233 status: DeviceServiceState["hostStatuses"][string],
219 ) =>234 ) =>
220 publish((state) => ({235 Effect.suspend(() =>
221 ...state,236 !hosts.has(hostId)
222 ...(hostId === LOCAL_DEVICE_HOST_ID237 ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))
223 ? { hostStatus: status.status, hostStatusDetail: status.detail }238 : publish((state) => ({
224 : {}),239 ...state,
225 hostStatuses: { ...state.hostStatuses, [hostId]: status },240 ...(hostId === LOCAL_DEVICE_HOST_ID
226 }));241 ? { hostStatus: status.status, hostStatusDetail: status.detail }
242 : {}),
243 hostStatuses: { ...state.hostStatuses, [hostId]: status },
244 })),
245 );
227246
228 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(247 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
229 function* (hostId) {248 function* (hostId) {
...@@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
245 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),264 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
246 ),265 ),
247 );266 );
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 });
248 const { state } = yield* SynchronizedRef.get(stateRef);272 const { state } = yield* SynchronizedRef.get(stateRef);
249 if (state.hostStatuses[host.id]?.status !== "ready") {273 if (state.hostStatuses[host.id]?.status !== "ready") {
250 yield* setHostStatus(host.id, { status: "ready" });274 yield* setHostStatus(host.id, { status: "ready" });
...@@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
260 if (!(yield* readDeviceSettings).enabled) return null;284 if (!(yield* readDeviceSettings).enabled) return null;
261 const host = yield* resolveHost(hostId);285 const host = yield* resolveHost(hostId);
262 const summary = yield* host.summary;286 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;
264 return yield* readiness(host.id);289 return yield* readiness(host.id);
265 });290 });
266291
...@@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
270 if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;295 if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
271 const host = yield* resolveHost(hostId);296 const host = yield* resolveHost(hostId);
272 const summary = yield* host.summary;297 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;
274 const ready = yield* host300 const ready = yield* host
275 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))301 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
276 .pipe(302 .pipe(
...@@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
364 });390 });
365391
366 const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {392 const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
393 const host = hosts.get(ready.hostId);
367 const { devices, detail } = yield* fetchDevices(ready);394 const { devices, detail } = yield* fetchDevices(ready);
368 const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);395 const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
369 return yield* lifecycleLock.withPermit(396 return yield* lifecycleLock.withPermit(
370 Effect.gen(function* () {397 Effect.gen(function* () {
371 if (!(yield* readDeviceSettings).enabled)398 if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
372 return (yield* SynchronizedRef.get(stateRef)).state;399 return (yield* SynchronizedRef.get(stateRef)).state;
373 return yield* publish((state) => ({400 return yield* publish((state) => ({
374 ...state,401 ...state,
...@@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
578 ),605 ),
579 );606 );
580 }607 }
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 });
581 const openedAt = DateTime.formatIso(yield* DateTime.now);613 const openedAt = DateTime.formatIso(yield* DateTime.now);
582 const session: DeviceSession = {614 const session: DeviceSession = {
583 threadId: input.threadId,615 threadId: input.threadId,
...@@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
758 Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),790 Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
759 );791 );
760792
761 return DeviceService.of({793 return {
762 agentCli: Effect.fail(794 ...DeviceService.of({
763 new DeviceHostUnavailableError({795 testHost,
764 hostId: LOCAL_DEVICE_HOST_ID,796 agentCli: Effect.fail(
765 reason: "Agent CLI installation is unavailable in this device service.",797 new DeviceHostUnavailableError({
766 }),798 hostId: LOCAL_DEVICE_HOST_ID,
767 ),799 reason: "Agent CLI installation is unavailable in this device service.",
768 agentTarget: (input) =>800 }),
769 Effect.gen(function* () {801 ),
770 const ready = yield* agentReadinessIfSupported(input.hostId);802 agentTarget: (input) =>
771 if (!ready)803 Effect.gen(function* () {
772 return yield* new DeviceHostUnavailableError({804 const host = yield* resolveHost(input.hostId);
773 hostId: input.hostId,805 const ready = yield* agentReadinessIfSupported(input.hostId);
774 reason:806 if (!ready)
775 "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",807 return yield* new DeviceHostUnavailableError({
776 });808 hostId: input.hostId,
777 const configPath = yield* configureAgent(input.hostId, ready);809 reason:
778 return [810 "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
779 "--config",811 });
780 configPath,812 const configPath = yield* lifecycleLock.withPermit(
781 "--session",813 Effect.gen(function* () {
782 agentDeviceSession(input.threadId, input.hostId, input.deviceId),814 if (hosts.get(host.id) !== host)
783 ];815 return yield* new DeviceHostUnavailableError({
784 }),816 hostId: host.id,
785 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),817 reason: "Host configuration changed. Retry the operation.",
786 subscribe: PubSub.subscribe(statePubSub),818 });
787 configure,819 return yield* configureAgent(input.hostId, ready);
788 list,820 }),
789 open,821 );
790 close,822 return [
791 shutdown,823 "--config",
792 detail,824 configPath,
793 action,825 "--session",
794 screenshot,826 agentDeviceSession(input.threadId, input.hostId, input.deviceId),
795 readiness,827 ];
796 readinessIfSupported,828 }),
797 agentReadinessIfSupported,829 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
798 currentReadiness,830 subscribe: PubSub.subscribe(statePubSub),
799 sessionsForThread,831 configure,
800 });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 };
801});863});
802864
803/** @public Service construction is part of the canonical Effect module API. */865/** @public Service construction is part of the canonical Effect module API. */
...@@ -807,7 +869,12 @@ export const make = Effect.gen(function* () {...@@ -807,7 +869,12 @@ export const make = Effect.gen(function* () {
807 const fs = yield* FileSystem.FileSystem;869 const fs = yield* FileSystem.FileSystem;
808 const path = yield* Path.Path;870 const path = yield* Path.Path;
809 const runner = yield* ProcessRunner.ProcessRunner;871 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) => {
811 const file = agentDeviceConfigPath(config.stateDir, hostId, path);878 const file = agentDeviceConfigPath(config.stateDir, hostId, path);
812 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(879 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
813 Effect.provideService(FileSystem.FileSystem, fs),880 Effect.provideService(FileSystem.FileSystem, fs),
...@@ -822,7 +889,108 @@ export const make = Effect.gen(function* () {...@@ -822,7 +889,108 @@ export const make = Effect.gen(function* () {
822 ),889 ),
823 Effect.as(file),890 Effect.as(file),
824 );891 );
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 );
826 return {994 return {
827 ...service,995 ...service,
828 agentCli: ensureAgentDevice(config.baseDir).pipe(996 agentCli: ensureAgentDevice(config.baseDir).pipe(
apps/server/src/device/DeviceToolchain.ts+2-2
...@@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore";...@@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore";
25import * as ProcessRunner from "../processRunner.ts";25import * as ProcessRunner from "../processRunner.ts";
2626
27const DEVICE_HUB_PACKAGE = "expo-device-hub";27const DEVICE_HUB_PACKAGE = "expo-device-hub";
28const DEVICE_HUB_VERSION = "0.9.0";28export const DEVICE_HUB_VERSION = "0.9.0";
29const AGENT_DEVICE_PACKAGE = "agent-device";29const AGENT_DEVICE_PACKAGE = "agent-device";
30const AGENT_DEVICE_VERSION = "0.20.10";30export const AGENT_DEVICE_VERSION = "0.20.10";
3131
32const INSTALL_TIMEOUT = Duration.minutes(10);32const INSTALL_TIMEOUT = Duration.minutes(10);
33const installLock = Semaphore.makeUnsafe(1);33const installLock = Semaphore.makeUnsafe(1);
apps/server/src/device/LocalDeviceHost.ts+1
...@@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
644644
645 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({645 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
646 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,646 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
647 nodePath: process.execPath,
647 run,648 run,
648 helpers: running.helpers,649 helpers: running.helpers,
649 });650 });
apps/server/src/device/SshDeviceHost.test.ts created+131
...@@ -0,0 +1,131 @@
1// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout.
2import { expect, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Net from "@t3tools/shared/Net";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11import * as HttpClient from "effect/unstable/http/HttpClient";
12import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
13import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
14import * as ServerConfig from "../config.ts";
15import * as DeviceHost from "./DeviceHost.ts";
16import * as SshDeviceHost from "./SshDeviceHost.ts";
17
18it.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 created+434
...@@ -0,0 +1,434 @@
1import * as NodeCrypto from "node:crypto";
2import {
3 type DeviceHostSummary,
4 DevicePlatformAvailability,
5 type SshDeviceHostConfig,
6} from "@t3tools/contracts";
7import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
8import * as NetService from "@t3tools/shared/Net";
9import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
10import * as Exit from "effect/Exit";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";
18import * as HttpClient from "effect/unstable/http/HttpClient";
19import * as ChildProcess from "effect/unstable/process/ChildProcess";
20import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
21import * as ServerConfig from "../config.ts";
22import * as DeviceHost from "./DeviceHost.ts";
23import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
24
25const Probe = Schema.Struct({
26 nodePath: Schema.String,
27 platforms: Schema.Array(DevicePlatformAvailability),
28});
29const 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});
40const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe));
41const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started));
42const targetFor = (config: SshDeviceHostConfig) => ({
43 alias: config.target,
44 hostname: config.target,
45 username: null,
46 port: config.port ?? null,
47});
48const identityArgs = (config: SshDeviceHostConfig) =>
49 config.identityFile ? ["-i", config.identityFile] : [];
50const commandArgs = (script: string) => [
51 "sh",
52 "-c",
53 quoteRemoteArg(remoteDeviceEnvironment + script),
54];
55const 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
73export 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
91export 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 created+220
...@@ -0,0 +1,220 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes.
2import * as Effect from "effect/Effect";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { describe, expect, it } from "@effect/vitest";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeFSP from "node:fs/promises";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeUtil from "node:util";
10import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
11import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
12
13const exec = NodeUtil.promisify(NodeChildProcess.execFile);
14
15it.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
39it.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
50describe("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';
75if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
76const 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';
81const args=process.argv.slice(2);
82const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
83const file=path.join(state,'daemon.json');
84if(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 {} }
85else 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?.();}); }
86else { 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 created+192
...@@ -0,0 +1,192 @@
1import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
2
3export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
4
5/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */
6export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
7if [ -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
10fi
11if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi
12if [ -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
16fi
17if [ -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. */
21export const remoteDeviceScript = (
22 owner: string,
23 mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
24) =>
25 `
26const owner = ${JSON.stringify(owner)};
27const mode = ${JSON.stringify(mode)};
28const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
29const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)};
30` +
31 String.raw`
32const fs = require('node:fs');
33const path = require('node:path');
34const os = require('node:os');
35const net = require('node:net');
36const { spawn, spawnSync } = require('node:child_process');
37const root = path.join(os.homedir(), '.t3', 'device');
38const state = path.join(root, 'hosts', owner);
39const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
40const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
41const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
42const 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};
49const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
50const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } };
51const 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)); }); });
52async 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}
79async 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
...@@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({...@@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
92 screenshot: () => Effect.succeed({ device, png }),92 screenshot: () => Effect.succeed({ device, png }),
93 close: () => Effect.void,93 close: () => Effect.void,
94 agentCli: Effect.succeed("/cli"),94 agentCli: Effect.succeed("/cli"),
95 testHost: () => Effect.die("not used"),
95 agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),96 agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
96});97});
9798
apps/server/src/mcp/toolkits/device/handlers.ts+3
...@@ -134,6 +134,9 @@ const handlers = {...@@ -134,6 +134,9 @@ const handlers = {
134 .filter((session) => session.threadId === scope.threadId)134 .filter((session) => session.threadId === scope.threadId)
135 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));135 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
136 return {136 return {
137 hostStatuses: Object.fromEntries(
138 Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId),
139 ),
137 hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,140 hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
138 devices: hostId141 devices: hostId
139 ? state.devices.filter((device) => device.hostId === hostId)142 ? state.devices.filter((device) => device.hostId === hostId)
apps/server/src/ws.ts+4
...@@ -2770,6 +2770,10 @@ const makeWsRpcLayer = (...@@ -2770,6 +2770,10 @@ const makeWsRpcLayer = (
2770 observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {2770 observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {
2771 "rpc.aggregate": "device",2771 "rpc.aggregate": "device",
2772 }),2772 }),
2773 [WS_METHODS.deviceTestHost]: (input) =>
2774 observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
2775 "rpc.aggregate": "device",
2776 }),
2773 [WS_METHODS.deviceList]: (_input) =>2777 [WS_METHODS.deviceList]: (_input) =>
2774 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {2778 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
2775 "rpc.aggregate": "device",2779 "rpc.aggregate": "device",
apps/web/src/components/device/DeviceHostAvailability.tsx created+27
...@@ -0,0 +1,27 @@
1import type { DevicePlatformAvailability } from "@t3tools/contracts";
2import { Check, Minus } from "lucide-react";
3import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
4
5export 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 created+334
...@@ -0,0 +1,334 @@
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import { AppleIcon, AndroidIcon } from "../Icons";
3import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
4import { Spinner } from "../ui/spinner";
5import type {
6 DevicePlatformAvailability,
7 EnvironmentId,
8 SshDeviceHostConfig,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { randomUUID } from "../../lib/utils";
12import { useState } from "react";
13import { deviceEnvironment, useDeviceState } from "../../state/device";
14import { serverEnvironment } from "../../state/server";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { Button } from "../ui/button";
17import { Input } from "../ui/input";
18import { MoreVertical, PlusIcon } from "lucide-react";
19import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
20import { SettingsRow } from "./settingsLayout";
21
22/** Host names and identity paths belong to the selected environment, never all environments. */
23export 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,3 +1,4 @@
1import { DeviceHostsSettings } from "./DeviceHostsSettings";
1/**2/**
2 * Integrations settings - preferences for surfaces T3 Code embeds rather than3 * Integrations settings - preferences for surfaces T3 Code embeds rather than
3 * owns. Browser is the first section: the defaults a preview tab opens at,4 * owns. Browser is the first section: the defaults a preview tab opens at,
...@@ -12,6 +13,7 @@ import {...@@ -12,6 +13,7 @@ import {
12 type BrowserLinkTarget,13 type BrowserLinkTarget,
13 type BrowserProfile,14 type BrowserProfile,
14 type EnvironmentId,15 type EnvironmentId,
16 type SshDeviceHostConfig,
15 BROWSER_PROFILE_NAME_MAX_LENGTH,17 BROWSER_PROFILE_NAME_MAX_LENGTH,
16 BROWSER_RECORDING_FRAME_RATES,18 BROWSER_RECORDING_FRAME_RATES,
17 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,19 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
...@@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() {...@@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() {
584586
585function DeviceIntegrationSettings() {587function DeviceIntegrationSettings() {
586 const primaryEnvironment = usePrimaryEnvironment();588 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
637function 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}) {
588 const { state, loaded } = useDeviceState(environmentId);648 const { state, loaded } = useDeviceState(environmentId);
589 const configure = useAtomCommand(deviceEnvironment.configure);649 const configure = useAtomCommand(deviceEnvironment.configure);
590 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });650 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
591 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);651 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
592 const enabled = state.hostStatus !== "disabled";
593 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";652 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
594 const [platformsRevealed, setPlatformsRevealed] = useState(false);653 const [platformsRevealed, setPlatformsRevealed] = useState(false);
595 // Keep diagnostics visible through subsequent agent setup and refresh phases.654 // Keep diagnostics visible through subsequent agent setup and refresh phases.
596 if (platformsRevealed && !enabled) setPlatformsRevealed(false);655 if (platformsRevealed && !enabled) setPlatformsRevealed(false);
597 if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {656 if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
598 setPlatformsRevealed(true);657 setPlatformsRevealed(true);
599 }658 }
600659
...@@ -615,7 +674,7 @@ function DeviceIntegrationSettings() {...@@ -615,7 +674,7 @@ function DeviceIntegrationSettings() {
615 };674 };
616675
617 return (676 return (
618 <SettingsSection id="devices" title="Devices">677 <>
619 <SettingsRow678 <SettingsRow
620 {...searchableSetting("device-hub")}679 {...searchableSetting("device-hub")}
621 description={deviceHubDescription}680 description={deviceHubDescription}
...@@ -674,7 +733,7 @@ function DeviceIntegrationSettings() {...@@ -674,7 +733,7 @@ function DeviceIntegrationSettings() {
674 <>733 <>
675 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}734 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
676 <Switch735 <Switch
677 checked={state.agentAccessEnabled}736 checked={agentAccessEnabled}
678 disabled={!loaded || !environmentId || !enabled || busy || pending !== null}737 disabled={!loaded || !environmentId || !enabled || busy || pending !== null}
679 aria-label="Agent device access"738 aria-label="Agent device access"
680 onCheckedChange={(checked) =>739 onCheckedChange={(checked) =>
...@@ -689,7 +748,8 @@ function DeviceIntegrationSettings() {...@@ -689,7 +748,8 @@ function DeviceIntegrationSettings() {
689 {state.hostStatusDetail}748 {state.hostStatusDetail}
690 </p>749 </p>
691 ) : null}750 ) : null}
692 </SettingsSection>751 <DeviceHostsSettings environmentId={environmentId} hosts={hosts} />
752 </>
693 );753 );
694}754}
695755
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [
402 to: "/settings/projects",402 to: "/settings/projects",
403 searchTerms: ["allow open drive preview tools sessions"],403 searchTerms: ["allow open drive preview tools sessions"],
404 },404 },
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 },
405 {411 {
406 id: "agent-device-access",412 id: "agent-device-access",
407 title: "Agent device access",413 title: "Agent device access",
docs/internals/devices.md+5-7
...@@ -3,8 +3,7 @@...@@ -3,8 +3,7 @@
3The environment server owns simulators and emulators the way it owns3The environment server owns simulators and emulators the way it owns
4terminals: discovery, streaming, and agent access all run there, and every4terminals: discovery, streaming, and agent access all run there, and every
5client reaches them through the environment connection. This is what makes the5client reaches them through the environment connection. This is what makes the
6Device panel work over Tailscale and T3 Connect, and what will let a device6Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices.
7host on another machine slot in later.
87
9## Two external tools, one seam8## Two external tools, one seam
109
...@@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down....@@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down.
21Everything platform-specific sits behind20Everything platform-specific sits behind
22[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the21[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the
23proxy, and the MCP tools only see a hub origin and an agent-device endpoint.22proxy, and the MCP tools only see a hub origin and an agent-device endpoint.
24An SSH or cloud host would forward those two things to the server and change23SSH hosts forward both endpoints to server loopback. Every proxied request
25nothing above it.24also carries the host id; device ids alone are not unique across hosts.
2625
27## The hub is never exposed26## The hub is never exposed
2827
...@@ -57,9 +56,8 @@ screenshot capture and stream tuning....@@ -57,9 +56,8 @@ screenshot capture and stream tuning.
57The `device_*` toolkit is deliberately four tools: list, open, screenshot, and56The `device_*` toolkit is deliberately four tools: list, open, screenshot, and
58close. Driving happens through the `agent-device` CLI, which has the semantic57close. Driving happens through the `agent-device` CLI, which has the semantic
59snapshot model agents need and stays current with its own releases. T3 prepends58snapshot model agents need and stays current with its own releases. T3 prepends
60a shim directory to the provider's PATH and sets59a shim directory to the provider's PATH. The CLI installs on the environment
61`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the60server even when that server cannot run simulators. Hosts start on demand.
62agent never handles the endpoint or token.
6361
64That environment is fixed when the provider subprocess spawns, so62That environment is fixed when the provider subprocess spawns, so
65[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)63[`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....@@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it.
1515
16Choose a running device to watch it, or choose **Start** next to a stopped16Choose a running device to watch it, or choose **Start** next to a stopped
17device to boot it. The panel shows when you or an agent starts a device.17device to boot it. The panel shows when you or an agent starts a device.
18Each device opens in its own tab. Use **+ → Device** to open another, and
19double-click a tab name or choose **Rename** from its context menu to rename it.
20Only the visible tab streams video; switching tabs keeps both devices running.
18Turn off the device hub in **Settings → Integrations → Devices** to stop the21Turn off the device hub in **Settings → Integrations → Devices** to stop the
19helper processes; simulators and emulators keep running until you power them22helper processes; simulators and emulators keep running until you power them
20off.23off.
...@@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices....@@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices.
29The screen is interactive: click and drag to touch, type while the screen is32The screen is interactive: click and drag to touch, type while the screen is
30focused, and use the toolbar for Home, Back, and Recents on Android, rotate on33focused, and use the toolbar for Home, Back, and Recents on Android, rotate on
31iOS, and power off. Close the tab to stop watching; the device keeps running34iOS, and power off. Close the tab to stop watching; the device keeps running
32unless you power it off.35unless you power it off. Closed tabs stay closed after a reload. To watch the
36device again, choose it from **+ → Device**.
3337
34## Tools38## Tools
3539
...@@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the...@@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the
61local network, Tailscale, and T3 Connect. Live video needs a secure page65local network, Tailscale, and T3 Connect. Live video needs a secure page
62(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower66(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower
63still-image stream and Android cannot show video.67still-image stream and Android cannot show video.
68
69## SSH device hosts
70
71In Settings → Integrations → Devices, select one connected environment
72and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with
73an optional identity file and port. These resolve on the environment server,
74so use the SSH configuration and keys available there. Password prompts are
75not supported.
76
77**Test connection** checks SSH, Node, npm, and platform tools without installing
78anything. The first device listing installs pinned device tools on the host.
79Node 22 or newer and npm must be available to non-interactive SSH commands.
80T3 checks common Homebrew and Android SDK locations; custom installations need
81the appropriate PATH and ANDROID_HOME on the host.
82
83The picker identifies devices by host when several hosts are configured.
84Connections recover after interruptions. Removing a host closes its device
85sessions and stops its T3 helpers when reachable; simulators keep running.
86
87T3 provides discovery, streaming, and control. Arrange app builds,
88installation, and connectivity to development servers such as Metro separately.
89A simulator on another machine cannot reach Metro through your environment's
90localhost without forwarding or another reachable address.
packages/client-runtime/src/state/device.ts+4
...@@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms<R, E>(...@@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms<R, E>(
28 scheduler,28 scheduler,
29 concurrency,29 concurrency,
30 }),30 }),
31 testHost: createEnvironmentRpcCommand(runtime, {
32 label: "environment-data:device:test-host",
33 tag: WS_METHODS.deviceTestHost,
34 }),
31 list: createEnvironmentRpcCommand(runtime, {35 list: createEnvironmentRpcCommand(runtime, {
32 label: "environment-data:device:list",36 label: "environment-data:device:list",
33 tag: WS_METHODS.deviceList,37 tag: WS_METHODS.deviceList,
packages/contracts/src/device.ts+23-1
...@@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type;...@@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type;
25/** The server machine. Always present; other host kinds are future work. */25/** The server machine. Always present; other host kinds are future work. */
26export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;26export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
2727
28/** SSH aliases and key paths are resolved on the environment server. */
29export 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});
39export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
40
41export 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
28/** Simulator udid or adb serial (an AVD name while it is not running). */49/** Simulator udid or adb serial (an AVD name while it is not running). */
29export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));50export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
30export type DeviceId = typeof DeviceId.Type;51export type DeviceId = typeof DeviceId.Type;
...@@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;...@@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
5576
56export const DeviceHostSummary = Schema.Struct({77export const DeviceHostSummary = Schema.Struct({
57 id: DeviceHostId,78 id: DeviceHostId,
58 kind: Schema.Literals(["local"]),79 kind: Schema.Literals(["local", "ssh"]),
59 label: TrimmedNonEmptyString,80 label: TrimmedNonEmptyString,
60 platforms: Schema.Array(DevicePlatformAvailability),81 platforms: Schema.Array(DevicePlatformAvailability),
61 hubInstalled: Schema.Boolean,82 hubInstalled: Schema.Boolean,
...@@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type;...@@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type;
424// panel describe devices the same way.445// panel describe devices the same way.
425446
426export const DeviceToolListResult = Schema.Struct({447export const DeviceToolListResult = Schema.Struct({
448 hostStatuses: DeviceServiceState.fields.hostStatuses,
427 hosts: Schema.Array(DeviceHostSummary),449 hosts: Schema.Array(DeviceHostSummary),
428 devices: Schema.Array(DeviceSummary),450 devices: Schema.Array(DeviceSummary),
429 /** Devices already open in this thread's Device panel. */451 /** Devices already open in this thread's Device panel. */
packages/contracts/src/rpc.ts+10
...@@ -189,6 +189,8 @@ import {...@@ -189,6 +189,8 @@ import {
189 DeviceDetailInput,189 DeviceDetailInput,
190 DeviceError,190 DeviceError,
191 DeviceListInput,191 DeviceListInput,
192 SshDeviceHostConfig,
193 DeviceHostSummary,
192 DeviceOpenInput,194 DeviceOpenInput,
193 DeviceServiceState,195 DeviceServiceState,
194 DeviceSession,196 DeviceSession,
...@@ -328,6 +330,7 @@ export const WS_METHODS = {...@@ -328,6 +330,7 @@ export const WS_METHODS = {
328 // Device methods330 // Device methods
329 deviceConfigure: "device.configure",331 deviceConfigure: "device.configure",
330 deviceList: "device.list",332 deviceList: "device.list",
333 deviceTestHost: "device.testHost",
331 deviceOpen: "device.open",334 deviceOpen: "device.open",
332 deviceClose: "device.close",335 deviceClose: "device.close",
333 deviceShutdown: "device.shutdown",336 deviceShutdown: "device.shutdown",
...@@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov...@@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov
1101 stream: true,1104 stream: true,
1102});1105});
11031106
1107const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, {
1108 payload: SshDeviceHostConfig,
1109 success: DeviceHostSummary,
1110 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1111});
1112
1104const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {1113const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {
1105 payload: DeviceListInput,1114 payload: DeviceListInput,
1106 success: DeviceServiceState,1115 success: DeviceServiceState,
...@@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make(
1380 WsSubscribeDiscoveredLocalServersRpc,1389 WsSubscribeDiscoveredLocalServersRpc,
1381 WsDeviceConfigureRpc,1390 WsDeviceConfigureRpc,
1382 WsDeviceListRpc,1391 WsDeviceListRpc,
1392 WsDeviceTestHostRpc,
1383 WsDeviceOpenRpc,1393 WsDeviceOpenRpc,
1384 WsDeviceCloseRpc,1394 WsDeviceCloseRpc,
1385 WsDeviceShutdownRpc,1395 WsDeviceShutdownRpc,
packages/contracts/src/settings.test.ts+13
...@@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => {...@@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => {
759 expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");759 expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
760 });760 });
761});761});
762
763const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
764
765it("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
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SshDeviceHostConfigs } from "./device.ts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({...@@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({
983 enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),984 enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
984 /** Whether the server-local Device panel setup flow has been completed. */985 /** Whether the server-local Device panel setup flow has been completed. */
985 deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),986 deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
987 deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
986 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(988 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
987 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),989 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
988 ),990 ),
...@@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({
1258 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),1260 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
1259 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),1261 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
1260 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),1262 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
1263 deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
1261 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1264 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1262 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1265 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
1263 backgroundActivity: Schema.optionalKey(1266 backgroundActivity: Schema.optionalKey(
packages/shared/src/serverSettings.test.ts+10
...@@ -21,6 +21,16 @@ import {...@@ -21,6 +21,16 @@ import {
21} from "./serverSettings.ts";21} from "./serverSettings.ts";
2222
23describe("serverSettings helpers", () => {23describe("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
24 it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {34 it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
25 const project = { id: ProjectId.make("project-actions"), scripts: [] };35 const project = { id: ProjectId.make("project-actions"), scripts: [] };
26 const action = {36 const action = {
pnpm-lock.yaml+3
...@@ -540,6 +540,9 @@ importers:...@@ -540,6 +540,9 @@ importers:
540 '@t3tools/shared':540 '@t3tools/shared':
541 specifier: workspace:*541 specifier: workspace:*
542 version: link:../../packages/shared542 version: link:../../packages/shared
543 '@t3tools/ssh':
544 specifier: workspace:*
545 version: link:../../packages/ssh
543 '@t3tools/tailscale':546 '@t3tools/tailscale':
544 specifier: workspace:*547 specifier: workspace:*
545 version: link:../../packages/tailscale548 version: link:../../packages/tailscale