| author | |
| committer | |
| log | d2eeacd8ccde4d8763ca315b4cddf2964db93329 |
| tree | 71894c7b2f8f7624592ca34f32bc11c1e1d9bf0f |
| parent | 7734c6d71b6dd5df9c9b4856cff8e419b8837fe8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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* ready | 329 | 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 { |
| 46 | export interface AgentDeviceEndpoint { | 46 | export 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 | } |
| 52 | 52 | ||
| 53 | export interface DeviceHostReady { | 53 | export 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 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 6 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 5 | import { ServerSettingsService } from "../serverSettings.ts"; | 7 | import { 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"; |
| 60 | import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; | 62 | import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; |
| 61 | import * as ProcessRunner from "../processRunner.ts"; | 63 | import * as ProcessRunner from "../processRunner.ts"; |
| 62 | import * as DeviceHost from "./DeviceHost.ts"; | 64 | import * as DeviceHost from "./DeviceHost.ts"; |
| 65 | import * as SshDeviceHost from "./SshDeviceHost.ts"; | ||
| 66 | import * as Exit from "effect/Exit"; | ||
| 63 | import * as LocalDeviceHost from "./LocalDeviceHost.ts"; | 67 | import * as LocalDeviceHost from "./LocalDeviceHost.ts"; |
| 64 | 68 | ||
| 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) => |
| 154 | 161 | ||
| 155 | export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( | 162 | export 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_ID | 237 | ? 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 | ); | ||
| 227 | 246 | ||
| 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 | }); |
| 266 | 291 | ||
| ... | @@ -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* host | 300 | 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 | }); |
| 365 | 391 | ||
| 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 | ); |
| 760 | 792 | ||
| 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 | }); |
| 802 | 864 | ||
| 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"; |
| 25 | import * as ProcessRunner from "../processRunner.ts"; | 25 | import * as ProcessRunner from "../processRunner.ts"; |
| 26 | 26 | ||
| 27 | const DEVICE_HUB_PACKAGE = "expo-device-hub"; | 27 | const DEVICE_HUB_PACKAGE = "expo-device-hub"; |
| 28 | const DEVICE_HUB_VERSION = "0.9.0"; | 28 | export const DEVICE_HUB_VERSION = "0.9.0"; |
| 29 | const AGENT_DEVICE_PACKAGE = "agent-device"; | 29 | const AGENT_DEVICE_PACKAGE = "agent-device"; |
| 30 | const AGENT_DEVICE_VERSION = "0.20.10"; | 30 | export const AGENT_DEVICE_VERSION = "0.20.10"; |
| 31 | 31 | ||
| 32 | const INSTALL_TIMEOUT = Duration.minutes(10); | 32 | const INSTALL_TIMEOUT = Duration.minutes(10); |
| 33 | const installLock = Semaphore.makeUnsafe(1); | 33 | const 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* () { |
| 644 | 644 | ||
| 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. | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import * as Net from "@t3tools/shared/Net"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as PlatformError from "effect/PlatformError"; | ||
| 9 | import * as Sink from "effect/Sink"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 12 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | ||
| 13 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 14 | import * as ServerConfig from "../config.ts"; | ||
| 15 | import * as DeviceHost from "./DeviceHost.ts"; | ||
| 16 | import * as SshDeviceHost from "./SshDeviceHost.ts"; | ||
| 17 | |||
| 18 | it.effect("preserves installed status after probes and cleans failed agent activation", () => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const fs = yield* FileSystem.FileSystem; | ||
| 21 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 22 | const modes: string[] = []; | ||
| 23 | let forwards = 0; | ||
| 24 | let failForward = true; | ||
| 25 | let rejectConfig = true; | ||
| 26 | const spawner = ChildProcessSpawner.make((command) => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); | ||
| 29 | const forwarding = command.args.includes("-N"); | ||
| 30 | let output = ""; | ||
| 31 | if (forwarding) { | ||
| 32 | if (failForward) { | ||
| 33 | failForward = false; | ||
| 34 | return yield* PlatformError.systemError({ | ||
| 35 | _tag: "AlreadyExists", | ||
| 36 | module: "ChildProcess", | ||
| 37 | method: "spawn", | ||
| 38 | description: "Port already bound", | ||
| 39 | }); | ||
| 40 | } | ||
| 41 | forwards++; | ||
| 42 | yield* Effect.addFinalizer(() => | ||
| 43 | Effect.sync(() => { | ||
| 44 | forwards--; | ||
| 45 | }), | ||
| 46 | ); | ||
| 47 | } else { | ||
| 48 | const stdin = command.options.stdin; | ||
| 49 | if ( | ||
| 50 | !stdin || | ||
| 51 | typeof stdin !== "object" || | ||
| 52 | !("stream" in stdin) || | ||
| 53 | !Stream.isStream(stdin.stream) | ||
| 54 | ) | ||
| 55 | return yield* Effect.die("Missing script"); | ||
| 56 | const script = yield* stdin.stream.pipe( | ||
| 57 | Stream.decodeText(), | ||
| 58 | Stream.runFold( | ||
| 59 | () => "", | ||
| 60 | (a, b) => a + b, | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; | ||
| 64 | modes.push(mode); | ||
| 65 | output = JSON.stringify({ | ||
| 66 | nodePath: "/node", | ||
| 67 | platforms: [{ platform: "ios", available: true }], | ||
| 68 | hubPort: 1234, | ||
| 69 | helpers: { serveSimAxSettings: null, serveSimCli: null }, | ||
| 70 | ...(mode === "agent-start" | ||
| 71 | ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" } | ||
| 72 | : {}), | ||
| 73 | }); | ||
| 74 | } | ||
| 75 | return ChildProcessSpawner.makeHandle({ | ||
| 76 | pid: ChildProcessSpawner.ProcessId(123), | ||
| 77 | stdout: Stream.make(new TextEncoder().encode(output)), | ||
| 78 | stderr: Stream.empty, | ||
| 79 | all: Stream.empty, | ||
| 80 | exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), | ||
| 81 | isRunning: Effect.succeed(forwarding), | ||
| 82 | kill: () => Effect.void, | ||
| 83 | stdin: Sink.drain, | ||
| 84 | getInputFd: () => Sink.drain, | ||
| 85 | getOutputFd: () => Stream.empty, | ||
| 86 | unref: Effect.succeed(Effect.void), | ||
| 87 | }); | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | const host = yield* SshDeviceHost.make( | ||
| 91 | { id: "test", label: "Test", target: "test.example" }, | ||
| 92 | () => | ||
| 93 | rejectConfig | ||
| 94 | ? Effect.fail( | ||
| 95 | new DeviceHost.DeviceHostError({ | ||
| 96 | hostId: "test", | ||
| 97 | step: "configuring agent access", | ||
| 98 | cause: new Error("fixture failure"), | ||
| 99 | }), | ||
| 100 | ) | ||
| 101 | : Effect.void, | ||
| 102 | ).pipe( | ||
| 103 | Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), | ||
| 104 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 105 | Effect.provideService( | ||
| 106 | HttpClient.HttpClient, | ||
| 107 | HttpClient.make((request) => | ||
| 108 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), | ||
| 109 | ), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | yield* host.ensureReady(() => Effect.void); | ||
| 113 | expect(forwards).toBe(1); | ||
| 114 | expect(modes.filter((mode) => mode === "start")).toHaveLength(2); | ||
| 115 | yield* host.platformAvailability("ios"); | ||
| 116 | expect((yield* host.summary).hubInstalled).toBe(true); | ||
| 117 | const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result); | ||
| 118 | expect(failed._tag).toBe("Failure"); | ||
| 119 | expect(forwards).toBe(0); | ||
| 120 | expect(modes.at(-1)).toBe("stop-agent"); | ||
| 121 | expect(yield* host.current).toBeNull(); | ||
| 122 | rejectConfig = false; | ||
| 123 | yield* host.ensureAgentReady(() => Effect.void); | ||
| 124 | yield* host.platformAvailability("ios"); | ||
| 125 | expect((yield* host.summary).agentDeviceInstalled).toBe(true); | ||
| 126 | yield* host.stopAgent; | ||
| 127 | expect(forwards).toBe(1); | ||
| 128 | yield* host.stop; | ||
| 129 | expect(forwards).toBe(0); | ||
| 130 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 131 | ); | ||
apps/server/src/device/SshDeviceHost.ts created+434| ... | @@ -0,0 +1,434 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import { | ||
| 3 | type DeviceHostSummary, | ||
| 4 | DevicePlatformAvailability, | ||
| 5 | type SshDeviceHostConfig, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; | ||
| 8 | import * as NetService from "@t3tools/shared/Net"; | ||
| 9 | import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as Scope from "effect/Scope"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 19 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | ||
| 20 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 21 | import * as ServerConfig from "../config.ts"; | ||
| 22 | import * as DeviceHost from "./DeviceHost.ts"; | ||
| 23 | import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; | ||
| 24 | |||
| 25 | const Probe = Schema.Struct({ | ||
| 26 | nodePath: Schema.String, | ||
| 27 | platforms: Schema.Array(DevicePlatformAvailability), | ||
| 28 | }); | ||
| 29 | const Started = Schema.Struct({ | ||
| 30 | ...Probe.fields, | ||
| 31 | hubPort: Schema.Int, | ||
| 32 | daemonPort: Schema.optionalKey(Schema.Int), | ||
| 33 | token: Schema.optionalKey(Schema.String), | ||
| 34 | entryPath: Schema.optionalKey(Schema.String), | ||
| 35 | helpers: Schema.Struct({ | ||
| 36 | serveSimAxSettings: Schema.NullOr(Schema.String), | ||
| 37 | serveSimCli: Schema.NullOr(Schema.String), | ||
| 38 | }), | ||
| 39 | }); | ||
| 40 | const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe)); | ||
| 41 | const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started)); | ||
| 42 | const targetFor = (config: SshDeviceHostConfig) => ({ | ||
| 43 | alias: config.target, | ||
| 44 | hostname: config.target, | ||
| 45 | username: null, | ||
| 46 | port: config.port ?? null, | ||
| 47 | }); | ||
| 48 | const identityArgs = (config: SshDeviceHostConfig) => | ||
| 49 | config.identityFile ? ["-i", config.identityFile] : []; | ||
| 50 | const commandArgs = (script: string) => [ | ||
| 51 | "sh", | ||
| 52 | "-c", | ||
| 53 | quoteRemoteArg(remoteDeviceEnvironment + script), | ||
| 54 | ]; | ||
| 55 | const bootstrap = ( | ||
| 56 | config: SshDeviceHostConfig, | ||
| 57 | owner: string, | ||
| 58 | mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", | ||
| 59 | ) => | ||
| 60 | runSshCommand(targetFor(config), { | ||
| 61 | preHostArgs: identityArgs(config), | ||
| 62 | remoteCommandArgs: commandArgs( | ||
| 63 | 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node', | ||
| 64 | ), | ||
| 65 | stdin: remoteDeviceScript(owner, mode), | ||
| 66 | timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, | ||
| 67 | }).pipe( | ||
| 68 | Effect.mapError( | ||
| 69 | (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }), | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | |||
| 73 | export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { | ||
| 74 | const result = yield* bootstrap(config, "probe", "probe"); | ||
| 75 | const value = yield* decodeProbe(result.stdout.trim()).pipe( | ||
| 76 | Effect.mapError( | ||
| 77 | (cause) => | ||
| 78 | new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), | ||
| 79 | ), | ||
| 80 | ); | ||
| 81 | return { | ||
| 82 | id: config.id, | ||
| 83 | label: config.label, | ||
| 84 | kind: "ssh", | ||
| 85 | hubInstalled: false, | ||
| 86 | agentDeviceInstalled: false, | ||
| 87 | platforms: value.platforms, | ||
| 88 | } satisfies DeviceHostSummary; | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const make = Effect.fn("SshDeviceHost.make")(function* ( | ||
| 92 | config: SshDeviceHostConfig, | ||
| 93 | onReady: ( | ||
| 94 | ready: DeviceHost.DeviceHostAgentReady, | ||
| 95 | ) => Effect.Effect<void, DeviceHost.DeviceHostError> = () => Effect.void, | ||
| 96 | onStatus: ( | ||
| 97 | status: "starting" | "ready" | "failed", | ||
| 98 | detail?: string, | ||
| 99 | ) => Effect.Effect<void> = () => Effect.void, | ||
| 100 | ) { | ||
| 101 | const fs = yield* FileSystem.FileSystem; | ||
| 102 | const path = yield* Path.Path; | ||
| 103 | const server = yield* ServerConfig.ServerConfig; | ||
| 104 | const net = yield* NetService.NetService; | ||
| 105 | const http = yield* HttpClient.HttpClient; | ||
| 106 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 107 | const parentScope = yield* Scope.Scope; | ||
| 108 | const ssh = yield* resolveSshCommand; | ||
| 109 | const environmentId = yield* fs | ||
| 110 | .readFileString(server.environmentIdPath) | ||
| 111 | .pipe(Effect.orElseSucceed(() => server.stateDir)); | ||
| 112 | const owner = NodeCrypto.createHash("sha256") | ||
| 113 | .update(`${environmentId}\0${server.stateDir}\0${config.id}`) | ||
| 114 | .digest("hex") | ||
| 115 | .slice(0, 24); | ||
| 116 | const provide = <A, E>( | ||
| 117 | effect: Effect.Effect< | ||
| 118 | A, | ||
| 119 | E, | ||
| 120 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | ||
| 121 | >, | ||
| 122 | ) => | ||
| 123 | effect.pipe( | ||
| 124 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 125 | Effect.provideService(Path.Path, path), | ||
| 126 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 127 | ); | ||
| 128 | const lock = yield* Semaphore.make(1); | ||
| 129 | let stopped = false; | ||
| 130 | let activated = false; | ||
| 131 | let wantsAgent = false; | ||
| 132 | let ready: | ||
| 133 | | (DeviceHost.DeviceHostReady & { | ||
| 134 | agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"]; | ||
| 135 | }) | ||
| 136 | | null = null; | ||
| 137 | let connectionScope: Scope.Closeable | null = null; | ||
| 138 | let summary: DeviceHostSummary = { | ||
| 139 | id: config.id, | ||
| 140 | label: config.label, | ||
| 141 | kind: "ssh", | ||
| 142 | hubInstalled: false, | ||
| 143 | agentDeviceInstalled: false, | ||
| 144 | platforms: [], | ||
| 145 | }; | ||
| 146 | |||
| 147 | const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => | ||
| 148 | provide( | ||
| 149 | runSshCommand(targetFor(config), { | ||
| 150 | preHostArgs: identityArgs(config), | ||
| 151 | remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`), | ||
| 152 | ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), | ||
| 153 | ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), | ||
| 154 | }), | ||
| 155 | ).pipe( | ||
| 156 | Effect.map((result) => ({ ...result, code: 0 })), | ||
| 157 | Effect.catch((error) => | ||
| 158 | Effect.succeed({ | ||
| 159 | stdout: "stdout" in error ? (error.stdout ?? "") : "", | ||
| 160 | stderr: error.message, | ||
| 161 | code: "exitCode" in error ? (error.exitCode ?? 127) : 127, | ||
| 162 | }), | ||
| 163 | ), | ||
| 164 | ); | ||
| 165 | |||
| 166 | const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< | ||
| 167 | DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, | ||
| 168 | DeviceHost.DeviceHostError | ||
| 169 | > { | ||
| 170 | activated = true; | ||
| 171 | const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); | ||
| 172 | yield* onStatus("starting"); | ||
| 173 | const remote = yield* decodeStarted(result.stdout.trim()).pipe( | ||
| 174 | Effect.mapError( | ||
| 175 | (cause) => | ||
| 176 | new DeviceHost.DeviceHostError({ | ||
| 177 | hostId: config.id, | ||
| 178 | step: "reading host endpoints", | ||
| 179 | cause, | ||
| 180 | }), | ||
| 181 | ), | ||
| 182 | ); | ||
| 183 | summary = { | ||
| 184 | ...summary, | ||
| 185 | platforms: remote.platforms, | ||
| 186 | hubInstalled: true, | ||
| 187 | agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, | ||
| 188 | }; | ||
| 189 | const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( | ||
| 190 | Effect.mapError( | ||
| 191 | (cause) => | ||
| 192 | new DeviceHost.DeviceHostError({ | ||
| 193 | hostId: config.id, | ||
| 194 | step: "reserving hub port", | ||
| 195 | cause, | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( | ||
| 200 | Effect.mapError( | ||
| 201 | (cause) => | ||
| 202 | new DeviceHost.DeviceHostError({ | ||
| 203 | hostId: config.id, | ||
| 204 | step: "reserving daemon port", | ||
| 205 | cause, | ||
| 206 | }), | ||
| 207 | ), | ||
| 208 | ); | ||
| 209 | const scope = yield* Scope.make(); | ||
| 210 | connectionScope = scope; | ||
| 211 | const child = yield* spawner | ||
| 212 | .spawn( | ||
| 213 | ChildProcess.make( | ||
| 214 | ssh, | ||
| 215 | [ | ||
| 216 | ...baseSshArgs(targetFor(config), { batchMode: "yes" }), | ||
| 217 | ...identityArgs(config), | ||
| 218 | "-o", | ||
| 219 | "ExitOnForwardFailure=yes", | ||
| 220 | "-o", | ||
| 221 | "ServerAliveInterval=10", | ||
| 222 | "-o", | ||
| 223 | "ServerAliveCountMax=3", | ||
| 224 | "-N", | ||
| 225 | "-L", | ||
| 226 | `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`, | ||
| 227 | ...(remote.daemonPort === undefined | ||
| 228 | ? [] | ||
| 229 | : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]), | ||
| 230 | config.target, | ||
| 231 | ], | ||
| 232 | { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, | ||
| 233 | ), | ||
| 234 | ) | ||
| 235 | .pipe( | ||
| 236 | Effect.provideService(Scope.Scope, scope), | ||
| 237 | Effect.mapError( | ||
| 238 | (cause) => | ||
| 239 | new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | let stderr = ""; | ||
| 243 | yield* child.stderr.pipe( | ||
| 244 | Stream.decodeText(), | ||
| 245 | Stream.runForEach((chunk) => | ||
| 246 | Effect.sync(() => { | ||
| 247 | stderr = (stderr + chunk).slice(-2000); | ||
| 248 | }), | ||
| 249 | ), | ||
| 250 | Effect.forkIn(scope), | ||
| 251 | ); | ||
| 252 | const next = { | ||
| 253 | nodePath: remote.nodePath, | ||
| 254 | hub: { origin: `http://127.0.0.1:${hubPort}` }, | ||
| 255 | ...(remote.daemonPort !== undefined && | ||
| 256 | remote.token !== undefined && | ||
| 257 | remote.entryPath !== undefined | ||
| 258 | ? { | ||
| 259 | agentDevice: { | ||
| 260 | baseUrl: `http://127.0.0.1:${daemonPort}`, | ||
| 261 | token: remote.token, | ||
| 262 | entryPath: remote.entryPath, | ||
| 263 | }, | ||
| 264 | } | ||
| 265 | : {}), | ||
| 266 | helpers: remote.helpers, | ||
| 267 | run, | ||
| 268 | }; | ||
| 269 | for (const [baseUrl, route] of [ | ||
| 270 | [next.hub.origin, "/readyz"], | ||
| 271 | ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []), | ||
| 272 | ]) { | ||
| 273 | yield* waitForHttpReady({ | ||
| 274 | baseUrl: baseUrl!, | ||
| 275 | path: route!, | ||
| 276 | timeoutMs: 15000, | ||
| 277 | makeError: () => | ||
| 278 | new DeviceHost.DeviceHostError({ | ||
| 279 | hostId: config.id, | ||
| 280 | step: "waiting for SSH forward", | ||
| 281 | cause: new Error(stderr || "Forwarded endpoint did not answer."), | ||
| 282 | }), | ||
| 283 | }).pipe(Effect.provideService(HttpClient.HttpClient, http)); | ||
| 284 | } | ||
| 285 | if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); | ||
| 286 | ready = next; | ||
| 287 | yield* onStatus("ready"); | ||
| 288 | // Reconnect also repairs helpers that died while SSH itself stayed connected. | ||
| 289 | const unhealthy = Effect.gen(function* () { | ||
| 290 | while (true) { | ||
| 291 | yield* Effect.sleep("10 seconds"); | ||
| 292 | const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe( | ||
| 293 | Effect.timeout("5 seconds"), | ||
| 294 | Effect.map((r) => r.status === 200), | ||
| 295 | Effect.orElseSucceed(() => false), | ||
| 296 | ); | ||
| 297 | const daemonAlive = next.agentDevice | ||
| 298 | ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe( | ||
| 299 | Effect.timeout("5 seconds"), | ||
| 300 | Effect.map((r) => r.status === 200), | ||
| 301 | Effect.orElseSucceed(() => false), | ||
| 302 | ) | ||
| 303 | : true; | ||
| 304 | if (!alive || !daemonAlive) return; | ||
| 305 | } | ||
| 306 | }); | ||
| 307 | yield* Effect.gen(function* () { | ||
| 308 | yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy); | ||
| 309 | if (stopped || connectionScope !== scope) return; | ||
| 310 | ready = null; | ||
| 311 | yield* onStatus("starting", "Reconnecting to device host…"); | ||
| 312 | yield* Scope.close(scope, Exit.void); | ||
| 313 | let delay = 1000; | ||
| 314 | while (true) { | ||
| 315 | if (stopped || connectionScope !== scope) return; | ||
| 316 | yield* Effect.sleep(delay); | ||
| 317 | const result = yield* lock | ||
| 318 | .withPermit( | ||
| 319 | Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))), | ||
| 320 | ) | ||
| 321 | .pipe(Effect.result); | ||
| 322 | if (result._tag === "Success") return; | ||
| 323 | yield* onStatus("failed", result.failure.message); | ||
| 324 | if (connectionScope && connectionScope !== scope) | ||
| 325 | yield* Scope.close(connectionScope, Exit.void); | ||
| 326 | connectionScope = scope; | ||
| 327 | delay = Math.min(delay * 2, 30000); | ||
| 328 | } | ||
| 329 | }).pipe(Effect.forkIn(parentScope)); | ||
| 330 | return next; | ||
| 331 | }); | ||
| 332 | |||
| 333 | const connect = Effect.fn("SshDeviceHost.connect")(function* () { | ||
| 334 | for (let attempt = 0; ; attempt++) { | ||
| 335 | const result = yield* connectOnce().pipe(Effect.result); | ||
| 336 | if (result._tag === "Success") return result.success; | ||
| 337 | const failedScope = connectionScope; | ||
| 338 | connectionScope = null; | ||
| 339 | if (failedScope) yield* Scope.close(failedScope, Exit.void); | ||
| 340 | // SSH binds after the reservation is released, so a competing bind needs fresh ports. | ||
| 341 | if ( | ||
| 342 | attempt >= 2 || | ||
| 343 | !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step) | ||
| 344 | ) | ||
| 345 | return yield* result.failure; | ||
| 346 | } | ||
| 347 | }); | ||
| 348 | |||
| 349 | const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => | ||
| 350 | lock.withPermit( | ||
| 351 | Effect.gen(function* () { | ||
| 352 | stopped = false; | ||
| 353 | if (ready) return ready; | ||
| 354 | summary = yield* provide(probe(config)); | ||
| 355 | yield* onPhase("installing"); | ||
| 356 | return yield* connect().pipe( | ||
| 357 | Effect.tapError(() => | ||
| 358 | connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, | ||
| 359 | ), | ||
| 360 | ); | ||
| 361 | }), | ||
| 362 | ); | ||
| 363 | const stop = lock.withPermit( | ||
| 364 | Effect.gen(function* () { | ||
| 365 | stopped = true; | ||
| 366 | ready = null; | ||
| 367 | if (connectionScope) yield* Scope.close(connectionScope, Exit.void); | ||
| 368 | connectionScope = null; | ||
| 369 | if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore); | ||
| 370 | activated = false; | ||
| 371 | wantsAgent = false; | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | const changeAgent = (enabled: boolean) => | ||
| 375 | lock.withPermit( | ||
| 376 | Effect.gen(function* () { | ||
| 377 | wantsAgent = enabled; | ||
| 378 | if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice }; | ||
| 379 | if (!enabled && !ready?.agentDevice) return null; | ||
| 380 | ready = null; | ||
| 381 | const previousScope = connectionScope; | ||
| 382 | connectionScope = null; | ||
| 383 | if (previousScope) yield* Scope.close(previousScope, Exit.void); | ||
| 384 | if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent")); | ||
| 385 | return yield* connect().pipe( | ||
| 386 | Effect.onError(() => | ||
| 387 | Effect.gen(function* () { | ||
| 388 | const failedScope = connectionScope; | ||
| 389 | connectionScope = null; | ||
| 390 | if (failedScope) yield* Scope.close(failedScope, Exit.void); | ||
| 391 | if (enabled) | ||
| 392 | yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore); | ||
| 393 | }), | ||
| 394 | ), | ||
| 395 | ); | ||
| 396 | }), | ||
| 397 | ); | ||
| 398 | yield* Effect.addFinalizer(() => stop); | ||
| 399 | return { | ||
| 400 | id: config.id, | ||
| 401 | summary: Effect.sync(() => summary), | ||
| 402 | current: Effect.sync(() => ready), | ||
| 403 | ensureReady, | ||
| 404 | ensureAgentReady: (onPhase) => | ||
| 405 | onPhase("installing").pipe( | ||
| 406 | Effect.flatMap(() => changeAgent(true)), | ||
| 407 | Effect.flatMap((value) => | ||
| 408 | value?.agentDevice | ||
| 409 | ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) | ||
| 410 | : Effect.fail( | ||
| 411 | new DeviceHost.DeviceHostError({ | ||
| 412 | hostId: config.id, | ||
| 413 | step: "starting agent tools", | ||
| 414 | cause: new Error("Daemon endpoint missing"), | ||
| 415 | }), | ||
| 416 | ), | ||
| 417 | ), | ||
| 418 | ), | ||
| 419 | stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), | ||
| 420 | stop, | ||
| 421 | platformAvailability: (platform) => | ||
| 422 | provide(probe(config)).pipe( | ||
| 423 | Effect.map((value) => { | ||
| 424 | summary = { ...summary, platforms: value.platforms }; | ||
| 425 | return value.platforms.find((p) => p.platform === platform)!; | ||
| 426 | }), | ||
| 427 | Effect.orElseSucceed(() => ({ | ||
| 428 | platform, | ||
| 429 | available: false, | ||
| 430 | reason: "Cannot reach device host. Test its SSH connection in Settings.", | ||
| 431 | })), | ||
| 432 | ), | ||
| 433 | } satisfies DeviceHost.DeviceHost["Service"]; | ||
| 434 | }); | ||
apps/server/src/device/sshDeviceScript.test.ts 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. | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as NodeChildProcess from "node:child_process"; | ||
| 6 | import * as NodeFSP from "node:fs/promises"; | ||
| 7 | import * as NodeOS from "node:os"; | ||
| 8 | import * as NodePath from "node:path"; | ||
| 9 | import * as NodeUtil from "node:util"; | ||
| 10 | import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; | ||
| 11 | import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; | ||
| 12 | |||
| 13 | const exec = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 14 | |||
| 15 | it.effect("finds Android Studio Java for a non-interactive SSH session", () => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 18 | yield* Effect.promise(async () => { | ||
| 19 | const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-")); | ||
| 20 | try { | ||
| 21 | const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr"); | ||
| 22 | await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true }); | ||
| 23 | await NodeFSP.writeFile( | ||
| 24 | NodePath.join(javaHome, "bin/java"), | ||
| 25 | "#!/bin/sh\necho test-java\n", | ||
| 26 | { mode: 0o755 }, | ||
| 27 | ); | ||
| 28 | const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], { | ||
| 29 | env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" }, | ||
| 30 | }); | ||
| 31 | expect(result.stdout.trim()).toBe("test-java"); | ||
| 32 | } finally { | ||
| 33 | await NodeFSP.rm(home, { recursive: true, force: true }); | ||
| 34 | } | ||
| 35 | }); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | it.effect("preserves shell metacharacters and newlines in remote arguments", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 42 | yield* Effect.promise(async () => { | ||
| 43 | const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; | ||
| 44 | const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); | ||
| 45 | expect(result.stdout).toBe(value); | ||
| 46 | }); | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | describe("remote helper lifecycle", () => { | ||
| 51 | it.effect("reuses its own healthy helpers and stops only its own runtime", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 54 | yield* Effect.promise(async () => { | ||
| 55 | const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); | ||
| 56 | const bin = NodePath.join(home, "bin"); | ||
| 57 | await NodeFSP.mkdir(bin); | ||
| 58 | await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); | ||
| 59 | const root = NodePath.join(home, ".t3/device"); | ||
| 60 | const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); | ||
| 61 | const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); | ||
| 62 | const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); | ||
| 63 | const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs"); | ||
| 64 | await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), { | ||
| 65 | recursive: true, | ||
| 66 | }); | ||
| 67 | await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), { | ||
| 68 | recursive: true, | ||
| 69 | }); | ||
| 70 | await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION); | ||
| 71 | await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); | ||
| 72 | await NodeFSP.writeFile( | ||
| 73 | hub, | ||
| 74 | `import http from 'node:http'; import fs from 'node:fs'; | ||
| 75 | if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} | ||
| 76 | const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, | ||
| 77 | ); | ||
| 78 | await NodeFSP.writeFile( | ||
| 79 | agent, | ||
| 80 | `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process'; | ||
| 81 | const args=process.argv.slice(2); | ||
| 82 | const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1]; | ||
| 83 | const file=path.join(state,'daemon.json'); | ||
| 84 | if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } | ||
| 85 | else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } | ||
| 86 | else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } | ||
| 87 | `, | ||
| 88 | ); | ||
| 89 | const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; | ||
| 90 | const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; | ||
| 91 | let invocation = 0; | ||
| 92 | const invoke = async ( | ||
| 93 | owner: string, | ||
| 94 | mode: "start" | "agent-start" | "stop-agent" | "stop", | ||
| 95 | upgraded = false, | ||
| 96 | ) => { | ||
| 97 | const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); | ||
| 98 | await NodeFSP.writeFile( | ||
| 99 | file, | ||
| 100 | `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + | ||
| 101 | remoteDeviceScript(owner, mode) | ||
| 102 | .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) | ||
| 103 | .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), | ||
| 104 | ); | ||
| 105 | const result = await exec(process.execPath, [file], { | ||
| 106 | env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, | ||
| 107 | }); | ||
| 108 | return result.stdout ? JSON.parse(result.stdout) : null; | ||
| 109 | }; | ||
| 110 | const template = NodePath.join(home, "hub-template"); | ||
| 111 | await NodeFSP.cp(hubDir, template, { recursive: true }); | ||
| 112 | await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); | ||
| 113 | const installLock = hubDir + ".lock"; | ||
| 114 | await NodeFSP.symlink("2147483647:exited-installer", installLock); | ||
| 115 | await NodeFSP.writeFile( | ||
| 116 | NodePath.join(bin, "npm"), | ||
| 117 | `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, | ||
| 118 | { mode: 0o755 }, | ||
| 119 | ); | ||
| 120 | await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); | ||
| 121 | await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); | ||
| 122 | try { | ||
| 123 | const [manual, concurrent] = await Promise.all([ | ||
| 124 | invoke("one", "start"), | ||
| 125 | invoke("one", "start"), | ||
| 126 | ]); | ||
| 127 | expect(concurrent.hubPort).toBe(manual.hubPort); | ||
| 128 | expect(manual.daemonPort).toBeUndefined(); | ||
| 129 | await expect( | ||
| 130 | NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), | ||
| 131 | ).rejects.toThrow(); | ||
| 132 | const [first, concurrentAgent] = await Promise.all([ | ||
| 133 | invoke("one", "agent-start"), | ||
| 134 | invoke("one", "agent-start"), | ||
| 135 | ]); | ||
| 136 | expect(concurrentAgent.hubPort).toBe(first.hubPort); | ||
| 137 | expect(concurrentAgent.daemonPort).toBe(first.daemonPort); | ||
| 138 | const second = await invoke("two", "agent-start"); | ||
| 139 | const reused = await invoke("one", "agent-start"); | ||
| 140 | expect(reused.hubPort).toBe(first.hubPort); | ||
| 141 | expect(reused.daemonPort).toBe(first.daemonPort); | ||
| 142 | expect(second.hubPort).not.toBe(first.hubPort); | ||
| 143 | expect(second.daemonPort).not.toBe(first.daemonPort); | ||
| 144 | const firstHub = JSON.parse( | ||
| 145 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), | ||
| 146 | ); | ||
| 147 | const secondHub = JSON.parse( | ||
| 148 | await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), | ||
| 149 | ); | ||
| 150 | await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); | ||
| 151 | let repaired = await invoke("one", "agent-start"); | ||
| 152 | expect(repaired.hubPort).not.toBe(first.hubPort); | ||
| 153 | const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")) | ||
| 154 | .trim() | ||
| 155 | .split("\n"); | ||
| 156 | expect(stopped).toContain(String(firstHub.pid)); | ||
| 157 | expect(stopped).not.toContain(String(secondHub.pid)); | ||
| 158 | const previousDaemon = JSON.parse( | ||
| 159 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | ||
| 160 | ); | ||
| 161 | for (const [source, name, version] of [ | ||
| 162 | [hubDir, "expo-device-hub", nextHubVersion], | ||
| 163 | [agentDir, "agent-device", nextAgentVersion], | ||
| 164 | ]) { | ||
| 165 | const destination = NodePath.join(root, `tools/${name}@${version}`); | ||
| 166 | await NodeFSP.cp(source!, destination, { recursive: true }); | ||
| 167 | await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!); | ||
| 168 | } | ||
| 169 | const upgraded = await invoke("one", "agent-start", true); | ||
| 170 | expect(upgraded.entryPath).toContain(nextAgentVersion); | ||
| 171 | const upgradedHub = JSON.parse( | ||
| 172 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), | ||
| 173 | ); | ||
| 174 | expect(upgradedHub.entryPath).toContain(nextHubVersion); | ||
| 175 | const upgradedDaemon = JSON.parse( | ||
| 176 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | ||
| 177 | ); | ||
| 178 | expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid); | ||
| 179 | expect(await invoke("one", "agent-start", true)).toEqual(upgraded); | ||
| 180 | await NodeFSP.writeFile( | ||
| 181 | NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`), | ||
| 182 | "", | ||
| 183 | ); | ||
| 184 | repaired = await invoke("one", "agent-start", true); | ||
| 185 | expect( | ||
| 186 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), | ||
| 187 | ).toBe(String(upgradedDaemon.pid)); | ||
| 188 | expect(repaired.daemonPort).not.toBe(upgraded.daemonPort); | ||
| 189 | // Stop still uses the recorded entry when a future pinned package is not installed yet. | ||
| 190 | const originalScript = remoteDeviceScript("one", "stop-agent"); | ||
| 191 | const upgradedStop = NodePath.join(home, "upgraded-stop.cjs"); | ||
| 192 | await NodeFSP.writeFile( | ||
| 193 | upgradedStop, | ||
| 194 | originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), | ||
| 195 | ); | ||
| 196 | await exec(process.execPath, [upgradedStop], { | ||
| 197 | env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, | ||
| 198 | }); | ||
| 199 | const daemon = JSON.parse( | ||
| 200 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | ||
| 201 | ); | ||
| 202 | expect( | ||
| 203 | await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), | ||
| 204 | ).toBe(String(daemon.pid)); | ||
| 205 | expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true); | ||
| 206 | await invoke("one", "stop"); | ||
| 207 | expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); | ||
| 208 | expect( | ||
| 209 | JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8")) | ||
| 210 | .owner, | ||
| 211 | ).toBe("two"); | ||
| 212 | } finally { | ||
| 213 | await invoke("one", "stop").catch(() => {}); | ||
| 214 | await invoke("two", "stop").catch(() => {}); | ||
| 215 | await NodeFSP.rm(home, { recursive: true, force: true }); | ||
| 216 | } | ||
| 217 | }); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | }); | ||
apps/server/src/device/sshDeviceScript.ts created+192| ... | @@ -0,0 +1,192 @@ | ||
| 1 | import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; | ||
| 2 | |||
| 3 | export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; | ||
| 4 | |||
| 5 | /** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */ | ||
| 6 | export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" | ||
| 7 | if [ -z "$ANDROID_HOME" ]; then | ||
| 8 | if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk"; | ||
| 9 | elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi | ||
| 10 | fi | ||
| 11 | if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi | ||
| 12 | if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then | ||
| 13 | for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do | ||
| 14 | if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi | ||
| 15 | done | ||
| 16 | fi | ||
| 17 | if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi | ||
| 18 | `; | ||
| 19 | |||
| 20 | /** Node runs this on the host. All paths it returns belong to that host. */ | ||
| 21 | export const remoteDeviceScript = ( | ||
| 22 | owner: string, | ||
| 23 | mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", | ||
| 24 | ) => | ||
| 25 | ` | ||
| 26 | const owner = ${JSON.stringify(owner)}; | ||
| 27 | const mode = ${JSON.stringify(mode)}; | ||
| 28 | const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; | ||
| 29 | const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; | ||
| 30 | ` + | ||
| 31 | String.raw` | ||
| 32 | const fs = require('node:fs'); | ||
| 33 | const path = require('node:path'); | ||
| 34 | const os = require('node:os'); | ||
| 35 | const net = require('node:net'); | ||
| 36 | const { spawn, spawnSync } = require('node:child_process'); | ||
| 37 | const root = path.join(os.homedir(), '.t3', 'device'); | ||
| 38 | const state = path.join(root, 'hosts', owner); | ||
| 39 | const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); | ||
| 40 | const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; | ||
| 41 | const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; | ||
| 42 | const stopHub = hub => { | ||
| 43 | if (!hub || hub.owner !== owner) return; | ||
| 44 | const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; | ||
| 45 | if (command.includes(hub.entryPath) && command.includes(String(hub.port))) { | ||
| 46 | try { process.kill(hub.pid, 'SIGTERM'); } catch {} | ||
| 47 | } | ||
| 48 | }; | ||
| 49 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); | ||
| 50 | const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } }; | ||
| 51 | const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); }); | ||
| 52 | async function acquireLock(lock, complete = () => false) { | ||
| 53 | const deadline = Date.now() + 600000; | ||
| 54 | const token = process.pid + ':' + require('node:crypto').randomUUID(); | ||
| 55 | const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; | ||
| 56 | while (true) { | ||
| 57 | try { | ||
| 58 | // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner. | ||
| 59 | fs.symlinkSync(token, lock); | ||
| 60 | return () => { if (owner() === token) fs.unlinkSync(lock); }; | ||
| 61 | } catch (error) { | ||
| 62 | if (error.code !== 'EEXIST') throw error; | ||
| 63 | if (complete()) return null; | ||
| 64 | const previous = owner(); | ||
| 65 | if (previous === null) continue; | ||
| 66 | const pid = Number(previous.split(':')[0]); | ||
| 67 | if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock); | ||
| 68 | try { process.kill(pid, 0); } catch (error) { | ||
| 69 | if (error.code === 'ESRCH' && owner() === previous) { | ||
| 70 | try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } | ||
| 71 | continue; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.'); | ||
| 75 | await sleep(500); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | async function install(name, version, entry) { | ||
| 80 | const dir = path.join(root, 'tools', name + '@' + version); | ||
| 81 | const file = path.join(dir, 'node_modules', name, entry); | ||
| 82 | const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version; | ||
| 83 | if (complete()) return file; | ||
| 84 | fs.mkdirSync(path.dirname(dir), { recursive: true }); | ||
| 85 | const lock = dir + '.lock'; | ||
| 86 | const release = await acquireLock(lock, complete); | ||
| 87 | if (!release) return file; | ||
| 88 | let staging; | ||
| 89 | try { | ||
| 90 | if (complete()) return file; | ||
| 91 | staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-')); | ||
| 92 | const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); | ||
| 93 | if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000))); | ||
| 94 | if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name); | ||
| 95 | fs.writeFileSync(path.join(staging, '.install-complete'), version); | ||
| 96 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 97 | fs.renameSync(staging, dir); | ||
| 98 | return file; | ||
| 99 | } finally { | ||
| 100 | if (staging) fs.rmSync(staging, { recursive: true, force: true }); | ||
| 101 | release(); | ||
| 102 | } | ||
| 103 | } | ||
| 104 | (async () => { | ||
| 105 | const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0; | ||
| 106 | const android = run('adb', ['version']).status === 0; | ||
| 107 | const platforms = [ | ||
| 108 | { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) }, | ||
| 109 | { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, | ||
| 110 | ]; | ||
| 111 | if (mode === 'probe') { | ||
| 112 | if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); | ||
| 113 | if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); | ||
| 114 | console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; | ||
| 115 | } | ||
| 116 | fs.mkdirSync(state, { recursive: true, mode: 0o700 }); | ||
| 117 | // Serialize starts and stops for this environment/host owner, including agent startup. | ||
| 118 | const hostLock = path.join(state, 'runtime.lock'); | ||
| 119 | const releaseHost = await acquireLock(hostLock); | ||
| 120 | try { | ||
| 121 | const hubFile = path.join(state, 'hub.json'); | ||
| 122 | const daemonFile = path.join(state, 'daemon.json'); | ||
| 123 | const agentFile = path.join(state, 'agent.json'); | ||
| 124 | if (mode === 'stop' || mode === 'stop-agent') { | ||
| 125 | const hub = read(hubFile); | ||
| 126 | if (mode === 'stop' && hub && hub.owner === owner) { | ||
| 127 | stopHub(hub); | ||
| 128 | fs.rmSync(hubFile, { force: true }); | ||
| 129 | } | ||
| 130 | const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); | ||
| 131 | if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); | ||
| 135 | fs.mkdirSync(state, { recursive: true, mode: 0o700 }); | ||
| 136 | const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); | ||
| 137 | let hub = read(hubFile); | ||
| 138 | if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) { | ||
| 139 | stopHub(hub); | ||
| 140 | for (let attempt = 0; attempt < 5; attempt++) { | ||
| 141 | const hubPort = await port(); | ||
| 142 | const log = fs.openSync(path.join(state, 'hub.log'), 'a'); | ||
| 143 | const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], { | ||
| 144 | cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, | ||
| 145 | }); | ||
| 146 | try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); } | ||
| 147 | finally { fs.closeSync(log); } | ||
| 148 | child.unref(); | ||
| 149 | hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; | ||
| 150 | write(hubFile, hub); | ||
| 151 | const deadline = Date.now() + 30000; | ||
| 152 | let listening = false; | ||
| 153 | while (child.exitCode === null && child.signalCode === null) { | ||
| 154 | if (await healthy(hub.port, '/readyz')) { listening = true; break; } | ||
| 155 | if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); } | ||
| 156 | await sleep(200); | ||
| 157 | } | ||
| 158 | if (listening) break; | ||
| 159 | // Port reservation and binding happen in different processes. Retry an early exit with a fresh port. | ||
| 160 | fs.rmSync(hubFile, { force: true }); | ||
| 161 | if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log')); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | let agentResult = {}; | ||
| 165 | if (mode === 'agent-start') { | ||
| 166 | const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); | ||
| 167 | const previousAgent = read(agentFile)?.entryPath; | ||
| 168 | let daemon = read(daemonFile); | ||
| 169 | if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) { | ||
| 170 | const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]); | ||
| 171 | if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.'); | ||
| 172 | fs.rmSync(daemonFile, { force: true }); | ||
| 173 | daemon = null; | ||
| 174 | } | ||
| 175 | if (!daemon) { | ||
| 176 | fs.rmSync(daemonFile, { force: true }); | ||
| 177 | const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' }; | ||
| 178 | delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; | ||
| 179 | run(process.execPath, [agentEntry, 'devices', '--json'], { env }); | ||
| 180 | daemon = read(daemonFile); | ||
| 181 | } | ||
| 182 | if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state); | ||
| 183 | write(agentFile, { entryPath: agentEntry }); | ||
| 184 | agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry }; | ||
| 185 | } | ||
| 186 | const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); | ||
| 187 | const optional = file => fs.existsSync(file) ? file : null; | ||
| 188 | console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, | ||
| 189 | helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); | ||
| 190 | } finally { releaseHost(); } | ||
| 191 | })().catch(error => { console.error(error.message); process.exitCode = 1; }); | ||
| 192 | `; | ||
apps/server/src/mcp/McpDeviceToolkit.test.ts+1| ... | @@ -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 | }); |
| 97 | 98 |
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: hostId | 141 | 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 @@ | ||
| 1 | import type { DevicePlatformAvailability } from "@t3tools/contracts"; | ||
| 2 | import { Check, Minus } from "lucide-react"; | ||
| 3 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | ||
| 4 | |||
| 5 | export function DeviceHostAvailability({ | ||
| 6 | platforms, | ||
| 7 | }: { | ||
| 8 | platforms: ReadonlyArray<DevicePlatformAvailability>; | ||
| 9 | }) { | ||
| 10 | return ( | ||
| 11 | <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> | ||
| 12 | {platforms.map((platform) => ( | ||
| 13 | <Tooltip key={platform.platform}> | ||
| 14 | <TooltipTrigger render={<span tabIndex={0} className="inline-flex items-center gap-1" />}> | ||
| 15 | {platform.available ? <Check className="size-3" /> : <Minus className="size-3" />} | ||
| 16 | {platform.platform === "ios" ? "iOS" : "Android"}{" "} | ||
| 17 | {platform.available ? "available" : "unavailable"} | ||
| 18 | </TooltipTrigger> | ||
| 19 | <TooltipPopup> | ||
| 20 | {platform.reason ?? | ||
| 21 | (platform.platform === "ios" ? "iOS available" : "Android available")} | ||
| 22 | </TooltipPopup> | ||
| 23 | </Tooltip> | ||
| 24 | ))} | ||
| 25 | </div> | ||
| 26 | ); | ||
| 27 | } | ||
apps/web/src/components/settings/DeviceHostsSettings.tsx created+334| ... | @@ -0,0 +1,334 @@ | ||
| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | ||
| 2 | import { AppleIcon, AndroidIcon } from "../Icons"; | ||
| 3 | import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; | ||
| 4 | import { Spinner } from "../ui/spinner"; | ||
| 5 | import type { | ||
| 6 | DevicePlatformAvailability, | ||
| 7 | EnvironmentId, | ||
| 8 | SshDeviceHostConfig, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import { randomUUID } from "../../lib/utils"; | ||
| 12 | import { useState } from "react"; | ||
| 13 | import { deviceEnvironment, useDeviceState } from "../../state/device"; | ||
| 14 | import { serverEnvironment } from "../../state/server"; | ||
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 16 | import { Button } from "../ui/button"; | ||
| 17 | import { Input } from "../ui/input"; | ||
| 18 | import { MoreVertical, PlusIcon } from "lucide-react"; | ||
| 19 | import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; | ||
| 20 | import { SettingsRow } from "./settingsLayout"; | ||
| 21 | |||
| 22 | /** Host names and identity paths belong to the selected environment, never all environments. */ | ||
| 23 | export function DeviceHostsSettings(props: { | ||
| 24 | environmentId: EnvironmentId | null; | ||
| 25 | hosts: ReadonlyArray<SshDeviceHostConfig>; | ||
| 26 | }) { | ||
| 27 | const update = useAtomCommand(serverEnvironment.updateSettings); | ||
| 28 | const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); | ||
| 29 | const { state } = useDeviceState(props.environmentId); | ||
| 30 | const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null); | ||
| 31 | const [busy, setBusy] = useState(false); | ||
| 32 | const validPort = (port: number | undefined) => | ||
| 33 | port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); | ||
| 34 | const [checks, setChecks] = useState< | ||
| 35 | Record< | ||
| 36 | string, | ||
| 37 | { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string } | ||
| 38 | > | ||
| 39 | >({}); | ||
| 40 | const setCheck = (id: string, value: (typeof checks)[string]) => | ||
| 41 | setChecks((current) => ({ ...current, [id]: value })); | ||
| 42 | const save = async (hosts: ReadonlyArray<SshDeviceHostConfig>) => { | ||
| 43 | if (!props.environmentId) return; | ||
| 44 | setBusy(true); | ||
| 45 | try { | ||
| 46 | const saved = await update({ | ||
| 47 | environmentId: props.environmentId, | ||
| 48 | input: { patch: { deviceHosts: hosts } }, | ||
| 49 | }); | ||
| 50 | if (saved._tag === "Success") { | ||
| 51 | setEditing(null); | ||
| 52 | } | ||
| 53 | } finally { | ||
| 54 | setBusy(false); | ||
| 55 | } | ||
| 56 | }; | ||
| 57 | const testConnection = async (host: SshDeviceHostConfig) => { | ||
| 58 | if (!props.environmentId || checks[host.id]?.pending) return; | ||
| 59 | setCheck(host.id, { pending: true }); | ||
| 60 | try { | ||
| 61 | const summary = await test({ environmentId: props.environmentId, input: host }); | ||
| 62 | setCheck( | ||
| 63 | host.id, | ||
| 64 | summary._tag === "Failure" | ||
| 65 | ? { error: Cause.pretty(summary.cause) } | ||
| 66 | : { platforms: summary.value.platforms }, | ||
| 67 | ); | ||
| 68 | } catch (error) { | ||
| 69 | setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); | ||
| 70 | } | ||
| 71 | }; | ||
| 72 | return ( | ||
| 73 | <SettingsRow | ||
| 74 | id="device-hosts" | ||
| 75 | title="Device hosts" | ||
| 76 | description="Add remote machines with simulator or emulator runtimes installed, and this environment will connect over SSH and set up device tools automatically." | ||
| 77 | control={ | ||
| 78 | <Button | ||
| 79 | size="sm" | ||
| 80 | variant="outline" | ||
| 81 | disabled={busy || !props.environmentId || editing !== null} | ||
| 82 | onClick={() => { | ||
| 83 | setEditing({ id: randomUUID(), label: "", target: "" }); | ||
| 84 | }} | ||
| 85 | > | ||
| 86 | <PlusIcon className="size-3.5" /> Add host | ||
| 87 | </Button> | ||
| 88 | } | ||
| 89 | > | ||
| 90 | <div className="pt-3 pb-2"> | ||
| 91 | {!props.environmentId ? ( | ||
| 92 | <p className="text-sm text-muted-foreground"> | ||
| 93 | Select one connected environment to manage its device hosts. | ||
| 94 | </p> | ||
| 95 | ) : ( | ||
| 96 | <> | ||
| 97 | {props.hosts.map((host) => { | ||
| 98 | const status = state.hostStatuses[host.id]; | ||
| 99 | const check = checks[host.id]; | ||
| 100 | const platforms = | ||
| 101 | check?.platforms ?? | ||
| 102 | state.hosts.find((value) => value.id === host.id)?.platforms ?? | ||
| 103 | []; | ||
| 104 | const progress = check?.pending | ||
| 105 | ? "Checking connection…" | ||
| 106 | : status?.status === "installing" | ||
| 107 | ? "Installing device support…" | ||
| 108 | : status?.status === "starting" | ||
| 109 | ? "Connecting…" | ||
| 110 | : null; | ||
| 111 | const error = | ||
| 112 | check?.error ?? (status?.status === "failed" ? status.detail : undefined); | ||
| 113 | return ( | ||
| 114 | <div | ||
| 115 | key={host.id} | ||
| 116 | className="flex items-center gap-2 border-t border-border/50 py-2.5" | ||
| 117 | > | ||
| 118 | <div className="min-w-0 flex-1"> | ||
| 119 | <div className="flex min-w-0 items-center gap-2"> | ||
| 120 | <p className="truncate text-sm font-medium">{host.label}</p> | ||
| 121 | {platforms | ||
| 122 | .filter((platform) => platform.available) | ||
| 123 | .map((platform) => ( | ||
| 124 | <Tooltip key={platform.platform}> | ||
| 125 | <TooltipTrigger | ||
| 126 | render={ | ||
| 127 | <span | ||
| 128 | tabIndex={0} | ||
| 129 | role="img" | ||
| 130 | aria-label={ | ||
| 131 | platform.platform === "ios" | ||
| 132 | ? "iOS available" | ||
| 133 | : "Android available" | ||
| 134 | } | ||
| 135 | className="shrink-0 text-muted-foreground" | ||
| 136 | /> | ||
| 137 | } | ||
| 138 | > | ||
| 139 | {platform.platform === "ios" ? ( | ||
| 140 | <AppleIcon className="size-3.5" /> | ||
| 141 | ) : ( | ||
| 142 | <AndroidIcon className="size-3.5" /> | ||
| 143 | )} | ||
| 144 | </TooltipTrigger> | ||
| 145 | <TooltipPopup> | ||
| 146 | {platform.platform === "ios" ? "iOS available" : "Android available"} | ||
| 147 | </TooltipPopup> | ||
| 148 | </Tooltip> | ||
| 149 | ))} | ||
| 150 | </div> | ||
| 151 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> | ||
| 152 | {error ? ( | ||
| 153 | <div className="mt-1" role="status"> | ||
| 154 | <details className="text-xs text-destructive"> | ||
| 155 | <summary>Connection failed</summary> | ||
| 156 | <p className="mt-1 whitespace-pre-wrap break-words">{error}</p> | ||
| 157 | </details> | ||
| 158 | </div> | ||
| 159 | ) : null} | ||
| 160 | </div> | ||
| 161 | {progress ? ( | ||
| 162 | <span | ||
| 163 | role="status" | ||
| 164 | className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" | ||
| 165 | > | ||
| 166 | <Spinner className="size-3" /> | ||
| 167 | {progress} | ||
| 168 | </span> | ||
| 169 | ) : null} | ||
| 170 | <Menu> | ||
| 171 | <MenuTrigger | ||
| 172 | render={ | ||
| 173 | <Button | ||
| 174 | size="icon-sm" | ||
| 175 | variant="ghost-muted" | ||
| 176 | disabled={busy} | ||
| 177 | aria-label={host.label + " options"} | ||
| 178 | /> | ||
| 179 | } | ||
| 180 | > | ||
| 181 | <MoreVertical /> | ||
| 182 | </MenuTrigger> | ||
| 183 | <MenuPopup align="end"> | ||
| 184 | <MenuItem | ||
| 185 | onClick={() => { | ||
| 186 | setEditing(host); | ||
| 187 | }} | ||
| 188 | > | ||
| 189 | Edit | ||
| 190 | </MenuItem> | ||
| 191 | <MenuItem | ||
| 192 | variant="destructive" | ||
| 193 | onClick={() => | ||
| 194 | void save(props.hosts.filter((value) => value.id !== host.id)) | ||
| 195 | } | ||
| 196 | > | ||
| 197 | Remove | ||
| 198 | </MenuItem> | ||
| 199 | </MenuPopup> | ||
| 200 | </Menu> | ||
| 201 | <Button | ||
| 202 | size="sm" | ||
| 203 | variant="outline" | ||
| 204 | disabled={busy || progress !== null} | ||
| 205 | onClick={() => void testConnection(host)} | ||
| 206 | > | ||
| 207 | Test connection | ||
| 208 | </Button> | ||
| 209 | </div> | ||
| 210 | ); | ||
| 211 | })} | ||
| 212 | {editing ? ( | ||
| 213 | <form | ||
| 214 | className="space-y-3 border-t border-border/50 py-3" | ||
| 215 | onSubmit={(event) => { | ||
| 216 | event.preventDefault(); | ||
| 217 | void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); | ||
| 218 | }} | ||
| 219 | > | ||
| 220 | <label className="block space-y-1 text-sm"> | ||
| 221 | <span>Name</span> | ||
| 222 | <Input | ||
| 223 | required | ||
| 224 | value={editing.label} | ||
| 225 | disabled={busy} | ||
| 226 | onChange={(event) => setEditing({ ...editing, label: event.target.value })} | ||
| 227 | placeholder="Mac mini" | ||
| 228 | /> | ||
| 229 | </label> | ||
| 230 | <label className="block space-y-1 text-sm"> | ||
| 231 | <span>SSH target</span> | ||
| 232 | <Input | ||
| 233 | required | ||
| 234 | value={editing.target} | ||
| 235 | disabled={busy} | ||
| 236 | onChange={(event) => setEditing({ ...editing, target: event.target.value })} | ||
| 237 | placeholder="user@host or SSH alias" | ||
| 238 | /> | ||
| 239 | </label> | ||
| 240 | <label className="block space-y-1 text-sm"> | ||
| 241 | <span>Identity file, optional</span> | ||
| 242 | <Input | ||
| 243 | value={editing.identityFile ?? ""} | ||
| 244 | disabled={busy} | ||
| 245 | onChange={(event) => { | ||
| 246 | const { identityFile: _, ...rest } = editing; | ||
| 247 | setEditing( | ||
| 248 | event.target.value ? { ...rest, identityFile: event.target.value } : rest, | ||
| 249 | ); | ||
| 250 | }} | ||
| 251 | placeholder="~/.ssh/id_ed25519" | ||
| 252 | /> | ||
| 253 | </label> | ||
| 254 | <label className="block space-y-1 text-sm"> | ||
| 255 | <span>Port, optional</span> | ||
| 256 | <Input | ||
| 257 | type="number" | ||
| 258 | min={1} | ||
| 259 | max={65535} | ||
| 260 | value={editing.port ?? ""} | ||
| 261 | disabled={busy} | ||
| 262 | onChange={(event) => { | ||
| 263 | const { port: _, ...rest } = editing; | ||
| 264 | setEditing( | ||
| 265 | event.target.value ? { ...rest, port: Number(event.target.value) } : rest, | ||
| 266 | ); | ||
| 267 | }} | ||
| 268 | placeholder="SSH config default" | ||
| 269 | /> | ||
| 270 | </label> | ||
| 271 | <div className="flex flex-wrap gap-2"> | ||
| 272 | <Button | ||
| 273 | size="sm" | ||
| 274 | type="submit" | ||
| 275 | disabled={ | ||
| 276 | busy || | ||
| 277 | !editing.label.trim() || | ||
| 278 | !editing.target.trim() || | ||
| 279 | !validPort(editing.port) | ||
| 280 | } | ||
| 281 | > | ||
| 282 | Save host | ||
| 283 | </Button> | ||
| 284 | <Button | ||
| 285 | size="sm" | ||
| 286 | type="button" | ||
| 287 | variant="outline" | ||
| 288 | disabled={ | ||
| 289 | busy || | ||
| 290 | !editing.label.trim() || | ||
| 291 | !editing.target.trim() || | ||
| 292 | !validPort(editing.port) | ||
| 293 | } | ||
| 294 | onClick={() => void testConnection(editing)} | ||
| 295 | > | ||
| 296 | Test connection | ||
| 297 | </Button> | ||
| 298 | <Button | ||
| 299 | size="sm" | ||
| 300 | type="button" | ||
| 301 | variant="ghost" | ||
| 302 | disabled={busy} | ||
| 303 | onClick={() => { | ||
| 304 | setEditing(null); | ||
| 305 | }} | ||
| 306 | > | ||
| 307 | Cancel | ||
| 308 | </Button> | ||
| 309 | </div> | ||
| 310 | {checks[editing.id]?.pending ? ( | ||
| 311 | <span | ||
| 312 | role="status" | ||
| 313 | className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" | ||
| 314 | > | ||
| 315 | <Spinner className="size-3" /> | ||
| 316 | Checking connection… | ||
| 317 | </span> | ||
| 318 | ) : null} | ||
| 319 | {checks[editing.id]?.platforms ? ( | ||
| 320 | <DeviceHostAvailability platforms={checks[editing.id]?.platforms ?? []} /> | ||
| 321 | ) : null} | ||
| 322 | {checks[editing.id]?.error ? ( | ||
| 323 | <p role="alert" className="text-xs text-destructive"> | ||
| 324 | {checks[editing.id]?.error} | ||
| 325 | </p> | ||
| 326 | ) : null} | ||
| 327 | </form> | ||
| 328 | ) : null} | ||
| 329 | </> | ||
| 330 | )} | ||
| 331 | </div> | ||
| 332 | </SettingsRow> | ||
| 333 | ); | ||
| 334 | } | ||
apps/web/src/components/settings/IntegrationsSettings.tsx+66-6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { DeviceHostsSettings } from "./DeviceHostsSettings"; | ||
| 1 | /** | 2 | /** |
| 2 | * Integrations settings - preferences for surfaces T3 Code embeds rather than | 3 | * 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() { |
| 584 | 586 | ||
| 585 | function DeviceIntegrationSettings() { | 587 | function 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 | |||
| 637 | function DeviceIntegrationControls({ | ||
| 638 | environmentId, | ||
| 639 | hosts, | ||
| 640 | enabled, | ||
| 641 | agentAccessEnabled, | ||
| 642 | }: { | ||
| 643 | environmentId: EnvironmentId | null; | ||
| 644 | hosts: ReadonlyArray<SshDeviceHostConfig>; | ||
| 645 | enabled: boolean; | ||
| 646 | agentAccessEnabled: boolean; | ||
| 647 | }) { | ||
| 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 | } |
| 600 | 659 | ||
| ... | @@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { | ... | @@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { |
| 615 | }; | 674 | }; |
| 616 | 675 | ||
| 617 | return ( | 676 | return ( |
| 618 | <SettingsSection id="devices" title="Devices"> | 677 | <> |
| 619 | <SettingsRow | 678 | <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 | <Switch | 735 | <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 | } |
| 695 | 755 |
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 @@ |
| 3 | The environment server owns simulators and emulators the way it owns | 3 | The environment server owns simulators and emulators the way it owns |
| 4 | terminals: discovery, streaming, and agent access all run there, and every | 4 | terminals: discovery, streaming, and agent access all run there, and every |
| 5 | client reaches them through the environment connection. This is what makes the | 5 | client reaches them through the environment connection. This is what makes the |
| 6 | Device panel work over Tailscale and T3 Connect, and what will let a device | 6 | Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices. |
| 7 | host on another machine slot in later. | ||
| 8 | 7 | ||
| 9 | ## Two external tools, one seam | 8 | ## Two external tools, one seam |
| 10 | 9 | ||
| ... | @@ -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. |
| 21 | Everything platform-specific sits behind | 20 | Everything platform-specific sits behind |
| 22 | [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the | 21 | [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the |
| 23 | proxy, and the MCP tools only see a hub origin and an agent-device endpoint. | 22 | proxy, and the MCP tools only see a hub origin and an agent-device endpoint. |
| 24 | An SSH or cloud host would forward those two things to the server and change | 23 | SSH hosts forward both endpoints to server loopback. Every proxied request |
| 25 | nothing above it. | 24 | also carries the host id; device ids alone are not unique across hosts. |
| 26 | 25 | ||
| 27 | ## The hub is never exposed | 26 | ## The hub is never exposed |
| 28 | 27 | ||
| ... | @@ -57,9 +56,8 @@ screenshot capture and stream tuning. | ... | @@ -57,9 +56,8 @@ screenshot capture and stream tuning. |
| 57 | The `device_*` toolkit is deliberately four tools: list, open, screenshot, and | 56 | The `device_*` toolkit is deliberately four tools: list, open, screenshot, and |
| 58 | close. Driving happens through the `agent-device` CLI, which has the semantic | 57 | close. Driving happens through the `agent-device` CLI, which has the semantic |
| 59 | snapshot model agents need and stays current with its own releases. T3 prepends | 58 | snapshot model agents need and stays current with its own releases. T3 prepends |
| 60 | a shim directory to the provider's PATH and sets | 59 | a 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 the | 60 | server even when that server cannot run simulators. Hosts start on demand. |
| 62 | agent never handles the endpoint or token. | ||
| 63 | 61 | ||
| 64 | That environment is fixed when the provider subprocess spawns, so | 62 | That 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. |
| 15 | 15 | ||
| 16 | Choose a running device to watch it, or choose **Start** next to a stopped | 16 | Choose a running device to watch it, or choose **Start** next to a stopped |
| 17 | device to boot it. The panel shows when you or an agent starts a device. | 17 | device to boot it. The panel shows when you or an agent starts a device. |
| 18 | Each device opens in its own tab. Use **+ → Device** to open another, and | ||
| 19 | double-click a tab name or choose **Rename** from its context menu to rename it. | ||
| 20 | Only the visible tab streams video; switching tabs keeps both devices running. | ||
| 18 | Turn off the device hub in **Settings → Integrations → Devices** to stop the | 21 | Turn off the device hub in **Settings → Integrations → Devices** to stop the |
| 19 | helper processes; simulators and emulators keep running until you power them | 22 | helper processes; simulators and emulators keep running until you power them |
| 20 | off. | 23 | off. |
| ... | @@ -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. |
| 29 | The screen is interactive: click and drag to touch, type while the screen is | 32 | The screen is interactive: click and drag to touch, type while the screen is |
| 30 | focused, and use the toolbar for Home, Back, and Recents on Android, rotate on | 33 | focused, and use the toolbar for Home, Back, and Recents on Android, rotate on |
| 31 | iOS, and power off. Close the tab to stop watching; the device keeps running | 34 | iOS, and power off. Close the tab to stop watching; the device keeps running |
| 32 | unless you power it off. | 35 | unless you power it off. Closed tabs stay closed after a reload. To watch the |
| 36 | device again, choose it from **+ → Device**. | ||
| 33 | 37 | ||
| 34 | ## Tools | 38 | ## Tools |
| 35 | 39 | ||
| ... | @@ -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 |
| 61 | local network, Tailscale, and T3 Connect. Live video needs a secure page | 65 | local 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 slower | 66 | (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower |
| 63 | still-image stream and Android cannot show video. | 67 | still-image stream and Android cannot show video. |
| 68 | |||
| 69 | ## SSH device hosts | ||
| 70 | |||
| 71 | In Settings → Integrations → Devices, select one connected environment | ||
| 72 | and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with | ||
| 73 | an optional identity file and port. These resolve on the environment server, | ||
| 74 | so use the SSH configuration and keys available there. Password prompts are | ||
| 75 | not supported. | ||
| 76 | |||
| 77 | **Test connection** checks SSH, Node, npm, and platform tools without installing | ||
| 78 | anything. The first device listing installs pinned device tools on the host. | ||
| 79 | Node 22 or newer and npm must be available to non-interactive SSH commands. | ||
| 80 | T3 checks common Homebrew and Android SDK locations; custom installations need | ||
| 81 | the appropriate PATH and ANDROID_HOME on the host. | ||
| 82 | |||
| 83 | The picker identifies devices by host when several hosts are configured. | ||
| 84 | Connections recover after interruptions. Removing a host closes its device | ||
| 85 | sessions and stops its T3 helpers when reachable; simulators keep running. | ||
| 86 | |||
| 87 | T3 provides discovery, streaming, and control. Arrange app builds, | ||
| 88 | installation, and connectivity to development servers such as Metro separately. | ||
| 89 | A simulator on another machine cannot reach Metro through your environment's | ||
| 90 | localhost without forwarding or another reachable address. |
packages/client-runtime/src/state/device.ts+4| ... | @@ -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. */ |
| 26 | export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; | 26 | export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; |
| 27 | 27 | ||
| 28 | /** SSH aliases and key paths are resolved on the environment server. */ | ||
| 29 | export const SshDeviceHostConfig = Schema.Struct({ | ||
| 30 | id: DeviceHostId.check( | ||
| 31 | Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), | ||
| 32 | Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."), | ||
| 33 | ), | ||
| 34 | label: TrimmedNonEmptyString, | ||
| 35 | target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)), | ||
| 36 | identityFile: Schema.optional(TrimmedNonEmptyString), | ||
| 37 | port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | ||
| 38 | }); | ||
| 39 | export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type; | ||
| 40 | |||
| 41 | export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( | ||
| 42 | Schema.makeFilter( | ||
| 43 | (hosts) => | ||
| 44 | new Set(hosts.map((host) => host.id)).size === hosts.length || | ||
| 45 | "Device host ids must be unique.", | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | |||
| 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). */ |
| 29 | export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); | 50 | export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); |
| 30 | export type DeviceId = typeof DeviceId.Type; | 51 | export 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; |
| 55 | 76 | ||
| 56 | export const DeviceHostSummary = Schema.Struct({ | 77 | export 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. |
| 425 | 446 | ||
| 426 | export const DeviceToolListResult = Schema.Struct({ | 447 | export 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 methods | 330 | // 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 | }); |
| 1103 | 1106 | ||
| 1107 | const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, { | ||
| 1108 | payload: SshDeviceHostConfig, | ||
| 1109 | success: DeviceHostSummary, | ||
| 1110 | error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), | ||
| 1111 | }); | ||
| 1112 | |||
| 1104 | const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { | 1113 | const 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 | |||
| 763 | const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings); | ||
| 764 | |||
| 765 | it("validates remote device hosts and rejects ambiguous host ids", () => { | ||
| 766 | const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 }; | ||
| 767 | expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]); | ||
| 768 | expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow(); | ||
| 769 | expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow(); | ||
| 770 | expect(() => | ||
| 771 | decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }), | ||
| 772 | ).toThrow(); | ||
| 773 | expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); | ||
| 774 | }); |
packages/contracts/src/settings.ts+3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { SshDeviceHostConfigs } from "./device.ts"; | ||
| 1 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 2 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 3 | import * as Schema from "effect/Schema"; | 4 | import * 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"; |
| 22 | 22 | ||
| 23 | describe("serverSettings helpers", () => { | 23 | describe("serverSettings helpers", () => { |
| 24 | it("replaces SSH host lists when saving, editing, and removing hosts", () => { | ||
| 25 | const host = { id: "mini", label: "Mac mini", target: "mini" }; | ||
| 26 | const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); | ||
| 27 | expect(saved.deviceHosts).toEqual([host]); | ||
| 28 | const replacement = { ...host, target: "other-mini" }; | ||
| 29 | const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] }); | ||
| 30 | expect(edited.deviceHosts).toEqual([replacement]); | ||
| 31 | expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]); | ||
| 32 | }); | ||
| 33 | |||
| 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/shared | 542 | 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/tailscale | 548 | version: link:../../packages/tailscale |