diff --git a/apps/server/package.json b/apps/server/package.json
index 7d27d39d2eb3..84a8c6a5988b 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -40,6 +40,7 @@
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
+ "@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 97afa4f40775..d7d1be455fa1 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
+ [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts
index ed6b5a87335d..c22dcfe86610 100644
--- a/apps/server/src/device/DeviceActions.test.ts
+++ b/apps/server/src/device/DeviceActions.test.ts
@@ -16,6 +16,7 @@ const makeReady = (
) => {
const calls: Call[] = [];
const ready: DeviceHostReady = {
+ nodePath: process.execPath,
hub: { origin: "http://127.0.0.1:1" },
helpers,
run: (command, args, options) => {
diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts
index 12781b31b4b5..509dac526b93 100644
--- a/apps/server/src/device/DeviceActions.ts
+++ b/apps/server/src/device/DeviceActions.ts
@@ -327,7 +327,7 @@ const serveSimPermissions = (
reason: "helper_missing",
});
yield* ready
- .run(process.execPath, [
+ .run(ready.nodePath, [
cli,
"permissions",
input.decision,
diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts
index 8d1bafbc5dfc..34ff2769362e 100644
--- a/apps/server/src/device/DeviceHost.ts
+++ b/apps/server/src/device/DeviceHost.ts
@@ -46,11 +46,12 @@ export interface DeviceHubEndpoint {
export interface AgentDeviceEndpoint {
readonly baseUrl: string;
readonly token: string;
- /** Absolute path of the agent-device entry script for the provider PATH shim. */
+ /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */
readonly entryPath: string;
}
export interface DeviceHostReady {
+ readonly nodePath: string;
readonly hub: DeviceHubEndpoint;
/**
* Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub)
diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts
index d586dffa1567..7a40e1e53c72 100644
--- a/apps/server/src/device/DeviceMultiHost.test.ts
+++ b/apps/server/src/device/DeviceMultiHost.test.ts
@@ -1,5 +1,7 @@
import { expect, it } from "@effect/vitest";
import { ThreadId } from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as Fiber from "effect/Fiber";
import * as Effect from "effect/Effect";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
@@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",
Effect.gen(function* () {
const host = (id: string, failed = false): DeviceHost["Service"] => {
const ready = {
+ nodePath: process.execPath,
hub: { origin: `http://${id}` },
agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
@@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails",
summary: Effect.succeed({
id,
label: id,
- kind: "local",
+ kind: id === "b" ? "ssh" : "local",
hubInstalled: true,
agentDeviceInstalled: true,
- platforms: [{ platform: "android", available: true }],
+ platforms: id === "b" ? [] : [{ platform: "android", available: true }],
}),
platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
ensureReady: () =>
@@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails",
),
);
const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
- const service = yield* makeWithHosts(hosts).pipe(
- Effect.provideService(HttpClient.HttpClient, http),
- );
+ const writeStarted = yield* Deferred.make();
+ const finishWrite = yield* Deferred.make();
+ const order: string[] = [];
+ const service = yield* makeWithHosts(hosts, undefined, () =>
+ Effect.gen(function* () {
+ order.push("write started");
+ yield* Deferred.succeed(writeStarted, undefined);
+ yield* Deferred.await(finishWrite);
+ order.push("write finished");
+ return "/host-config.json";
+ }),
+ ).pipe(Effect.provideService(HttpClient.HttpClient, http));
+ expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();
const listed = yield* service.list;
expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
expect(listed.hostStatuses.offline?.status).toBe("failed");
@@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails",
expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
expect(state.hostStatuses.a?.status).toBe("ready");
expect(state.hostStatuses.offline?.status).toBe("failed");
- yield* service.agentReadinessIfSupported("b");
- expect((yield* service.state).hostStatuses.b?.status).toBe("ready");
+ const targeting = yield* service
+ .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(writeStarted);
+ const replacing = yield* service
+ .withLifecycleLock(
+ Effect.gen(function* () {
+ order.push("replace");
+ hosts.set("b", host("b"));
+ yield* service.refreshHosts;
+ }),
+ )
+ .pipe(Effect.forkChild);
+ yield* Deferred.succeed(finishWrite, undefined);
+ yield* Fiber.join(targeting);
+ yield* Fiber.join(replacing);
+ expect(order).toEqual(["write started", "write finished", "replace"]);
+ const replaced = yield* service.state;
+ expect(replaced.sessions).toEqual([]);
+ expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]);
+ expect(replaced.hostStatuses.b).toBeUndefined();
+ yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" });
+ hosts.delete("b");
+ yield* service.refreshHosts;
+ yield* service.setHostStatus("b", { status: "ready" });
+ expect((yield* service.state).hostStatuses.b).toBeUndefined();
+ expect((yield* service.state).sessions).toEqual([]);
+ yield* service.agentReadinessIfSupported("a");
+ expect((yield* service.state).hostStatuses.a?.status).toBe("ready");
yield* service.configure({ enabled: false });
expect((yield* service.state).hostStatuses).toEqual({});
}).pipe(
diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts
index b45db236fab6..46e159dbfc33 100644
--- a/apps/server/src/device/DeviceService.test.ts
+++ b/apps/server/src/device/DeviceService.test.ts
@@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* (
let booted = false;
let shutDown = false;
const ready: DeviceHost.DeviceHostReady = {
+ nodePath: process.execPath,
hub: { origin: "http://device.test" },
helpers: { serveSimAxSettings: null, serveSimCli: null },
run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts
index 866663dbf21c..a60cda76c648 100644
--- a/apps/server/src/device/DeviceService.ts
+++ b/apps/server/src/device/DeviceService.ts
@@ -30,6 +30,8 @@ import {
type DeviceSession,
type DeviceShutdownInput,
type DeviceSummary,
+ type SshDeviceHostConfig,
+ type DeviceHostSummary,
LOCAL_DEVICE_HOST_ID,
type ThreadId,
} from "@t3tools/contracts";
@@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts";
import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as DeviceHost from "./DeviceHost.ts";
+import * as SshDeviceHost from "./SshDeviceHost.ts";
+import * as Exit from "effect/Exit";
import * as LocalDeviceHost from "./LocalDeviceHost.ts";
/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
@@ -105,6 +109,9 @@ export class DeviceService extends Context.Service<
DeviceService,
{
readonly agentCli: Effect.Effect;
+ readonly testHost: (
+ config: SshDeviceHostConfig,
+ ) => Effect.Effect;
readonly agentTarget: (input: {
threadId: ThreadId;
hostId: DeviceHostId;
@@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) =>
export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
hosts: ReadonlyMap,
+ testHost: DeviceService["Service"]["testHost"] = (host) =>
+ Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "SSH probing is unavailable in this device service.",
+ }),
+ ),
configureAgent: (
hostId: DeviceHostId,
ready: DeviceHost.DeviceHostAgentReady,
@@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
const statePubSub = yield* PubSub.unbounded();
const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
+ let publishedHosts = new Map(hosts);
const stateRef = yield* SynchronizedRef.make({
state: {
hosts: initialHosts,
@@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
hostId: DeviceHostId,
status: DeviceServiceState["hostStatuses"][string],
) =>
- publish((state) => ({
- ...state,
- ...(hostId === LOCAL_DEVICE_HOST_ID
- ? { hostStatus: status.status, hostStatusDetail: status.detail }
- : {}),
- hostStatuses: { ...state.hostStatuses, [hostId]: status },
- }));
+ Effect.suspend(() =>
+ !hosts.has(hostId)
+ ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))
+ : publish((state) => ({
+ ...state,
+ ...(hostId === LOCAL_DEVICE_HOST_ID
+ ? { hostStatus: status.status, hostStatusDetail: status.detail }
+ : {}),
+ hostStatuses: { ...state.hostStatuses, [hostId]: status },
+ })),
+ );
const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
function* (hostId) {
@@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
(error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
),
);
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
const { state } = yield* SynchronizedRef.get(stateRef);
if (state.hostStatuses[host.id]?.status !== "ready") {
yield* setHostStatus(host.id, { status: "ready" });
@@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
if (!(yield* readDeviceSettings).enabled) return null;
const host = yield* resolveHost(hostId);
const summary = yield* host.summary;
- if (!summary.platforms.some((platform) => platform.available)) return null;
+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
+ return null;
return yield* readiness(host.id);
});
@@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
const host = yield* resolveHost(hostId);
const summary = yield* host.summary;
- if (!summary.platforms.some((platform) => platform.available)) return null;
+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
+ return null;
const ready = yield* host
.ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
.pipe(
@@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
});
const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
+ const host = hosts.get(ready.hostId);
const { devices, detail } = yield* fetchDevices(ready);
const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
return yield* lifecycleLock.withPermit(
Effect.gen(function* () {
- if (!(yield* readDeviceSettings).enabled)
+ if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
return (yield* SynchronizedRef.get(stateRef)).state;
return yield* publish((state) => ({
...state,
@@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
),
);
}
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
const openedAt = DateTime.formatIso(yield* DateTime.now);
const session: DeviceSession = {
threadId: input.threadId,
@@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
);
- return DeviceService.of({
- agentCli: Effect.fail(
- new DeviceHostUnavailableError({
- hostId: LOCAL_DEVICE_HOST_ID,
- reason: "Agent CLI installation is unavailable in this device service.",
- }),
- ),
- agentTarget: (input) =>
- Effect.gen(function* () {
- const ready = yield* agentReadinessIfSupported(input.hostId);
- if (!ready)
- return yield* new DeviceHostUnavailableError({
- hostId: input.hostId,
- reason:
- "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
- });
- const configPath = yield* configureAgent(input.hostId, ready);
- return [
- "--config",
- configPath,
- "--session",
- agentDeviceSession(input.threadId, input.hostId, input.deviceId),
- ];
- }),
- state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
- subscribe: PubSub.subscribe(statePubSub),
- configure,
- list,
- open,
- close,
- shutdown,
- detail,
- action,
- screenshot,
- readiness,
- readinessIfSupported,
- agentReadinessIfSupported,
- currentReadiness,
- sessionsForThread,
- });
+ return {
+ ...DeviceService.of({
+ testHost,
+ agentCli: Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId: LOCAL_DEVICE_HOST_ID,
+ reason: "Agent CLI installation is unavailable in this device service.",
+ }),
+ ),
+ agentTarget: (input) =>
+ Effect.gen(function* () {
+ const host = yield* resolveHost(input.hostId);
+ const ready = yield* agentReadinessIfSupported(input.hostId);
+ if (!ready)
+ return yield* new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason:
+ "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
+ });
+ const configPath = yield* lifecycleLock.withPermit(
+ Effect.gen(function* () {
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
+ return yield* configureAgent(input.hostId, ready);
+ }),
+ );
+ return [
+ "--config",
+ configPath,
+ "--session",
+ agentDeviceSession(input.threadId, input.hostId, input.deviceId),
+ ];
+ }),
+ state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
+ subscribe: PubSub.subscribe(statePubSub),
+ configure,
+ list,
+ open,
+ close,
+ shutdown,
+ detail,
+ action,
+ screenshot,
+ readiness,
+ readinessIfSupported,
+ agentReadinessIfSupported,
+ currentReadiness,
+ sessionsForThread,
+ }),
+ setHostStatus,
+ withLifecycleLock: lifecycleLock.withPermit,
+ refreshHosts: Effect.gen(function* () {
+ const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
+ const unchanged = (id: DeviceHostId) =>
+ hosts.has(id) && hosts.get(id) === publishedHosts.get(id);
+ yield* publish((state) => ({
+ ...state,
+ hosts: summaries,
+ hostStatuses: Object.fromEntries(
+ Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)),
+ ),
+ devices: state.devices.filter((device) => unchanged(device.hostId)),
+ sessions: state.sessions.filter((session) => unchanged(session.hostId)),
+ }));
+ publishedHosts = new Map(hosts);
+ }),
+ };
});
/** @public Service construction is part of the canonical Effect module API. */
@@ -807,7 +869,12 @@ export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
- const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => {
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const scope = yield* Scope.Scope;
+ const hosts = new Map([
+ [localHost.id, localHost],
+ ]);
+ const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => {
const file = agentDeviceConfigPath(config.stateDir, hostId, path);
return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
@@ -822,7 +889,108 @@ export const make = Effect.gen(function* () {
),
Effect.as(file),
);
- });
+ };
+ const probeContext =
+ yield* Effect.context>>();
+ const service = yield* makeWithHosts(
+ hosts,
+ (host) =>
+ SshDeviceHost.probe(host).pipe(
+ Effect.provide(probeContext),
+ Effect.mapError(
+ (error) =>
+ new DeviceOperationError({
+ operation: "probe host",
+ reason: "request_failed",
+ cause: error,
+ }),
+ ),
+ ),
+ configureAgent,
+ );
+ const hostContext =
+ yield* Effect.context>>();
+ const configured = new Map();
+ const reconcile = (next: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const removed = yield* service.withLifecycleLock(
+ Effect.gen(function* () {
+ const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
+ for (const [id, previous] of configured) {
+ if (
+ next.some(
+ (host) =>
+ host.id === id &&
+ host.label === previous.config.label &&
+ host.target === previous.config.target &&
+ host.port === previous.config.port &&
+ host.identityFile === previous.config.identityFile,
+ )
+ )
+ continue;
+ hosts.delete(id);
+ configured.delete(id);
+ removed.push({ id, scope: previous.scope });
+ }
+ yield* service.refreshHosts;
+ return removed;
+ }),
+ );
+ // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts.
+ yield* Effect.forEach(
+ removed,
+ ({ id, scope }) =>
+ Effect.gen(function* () {
+ yield* Scope.close(scope, Exit.void);
+ yield* fs
+ .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true })
+ .pipe(Effect.ignore);
+ }),
+ { concurrency: 4, discard: true },
+ );
+ yield* service.withLifecycleLock(
+ Effect.gen(function* () {
+ for (const host of next) {
+ if (configured.has(host.id)) continue;
+ const hostScope = yield* Scope.fork(scope);
+ const instance = yield* SshDeviceHost.make(
+ host,
+ (ready) =>
+ configureAgent(host.id, ready).pipe(
+ Effect.asVoid,
+ Effect.mapError(
+ (error) =>
+ new DeviceHost.DeviceHostError({
+ hostId: host.id,
+ step: "configuring agent access",
+ cause: error,
+ }),
+ ),
+ ),
+ (status, detail) =>
+ service
+ .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) })
+ .pipe(Effect.asVoid),
+ ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext));
+ hosts.set(host.id, instance);
+ configured.set(host.id, { config: host, scope: hostScope });
+ }
+ yield* service.refreshHosts;
+ }),
+ );
+ });
+ const changes = yield* settings.subscribeChanges;
+ yield* reconcile((yield* settings.getSettings).deviceHosts);
+ yield* changes.pipe(
+ Stream.runForEach((value) => reconcile(value.deviceHosts)),
+ Effect.forkIn(scope),
+ );
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), {
+ discard: true,
+ concurrency: 4,
+ }),
+ );
return {
...service,
agentCli: ensureAgentDevice(config.baseDir).pipe(
diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts
index e6b43cd81ee8..fa8d8cd11d17 100644
--- a/apps/server/src/device/DeviceToolchain.ts
+++ b/apps/server/src/device/DeviceToolchain.ts
@@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore";
import * as ProcessRunner from "../processRunner.ts";
const DEVICE_HUB_PACKAGE = "expo-device-hub";
-const DEVICE_HUB_VERSION = "0.9.0";
+export const DEVICE_HUB_VERSION = "0.9.0";
const AGENT_DEVICE_PACKAGE = "agent-device";
-const AGENT_DEVICE_VERSION = "0.20.10";
+export const AGENT_DEVICE_VERSION = "0.20.10";
const INSTALL_TIMEOUT = Duration.minutes(10);
const installLock = Semaphore.makeUnsafe(1);
diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts
index b24ebfbd98b6..5a18d6e826a6 100644
--- a/apps/server/src/device/LocalDeviceHost.ts
+++ b/apps/server/src/device/LocalDeviceHost.ts
@@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
+ nodePath: process.execPath,
run,
helpers: running.helpers,
});
diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts
new file mode 100644
index 000000000000..d968d3383fae
--- /dev/null
+++ b/apps/server/src/device/SshDeviceHost.test.ts
@@ -0,0 +1,131 @@
+// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout.
+import { expect, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Net from "@t3tools/shared/Net";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as PlatformError from "effect/PlatformError";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import * as ServerConfig from "../config.ts";
+import * as DeviceHost from "./DeviceHost.ts";
+import * as SshDeviceHost from "./SshDeviceHost.ts";
+
+it.effect("preserves installed status after probes and cleans failed agent activation", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const home = yield* fs.makeTempDirectoryScoped();
+ const modes: string[] = [];
+ let forwards = 0;
+ let failForward = true;
+ let rejectConfig = true;
+ const spawner = ChildProcessSpawner.make((command) =>
+ Effect.gen(function* () {
+ if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
+ const forwarding = command.args.includes("-N");
+ let output = "";
+ if (forwarding) {
+ if (failForward) {
+ failForward = false;
+ return yield* PlatformError.systemError({
+ _tag: "AlreadyExists",
+ module: "ChildProcess",
+ method: "spawn",
+ description: "Port already bound",
+ });
+ }
+ forwards++;
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ forwards--;
+ }),
+ );
+ } else {
+ const stdin = command.options.stdin;
+ if (
+ !stdin ||
+ typeof stdin !== "object" ||
+ !("stream" in stdin) ||
+ !Stream.isStream(stdin.stream)
+ )
+ return yield* Effect.die("Missing script");
+ const script = yield* stdin.stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (a, b) => a + b,
+ ),
+ );
+ const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
+ modes.push(mode);
+ output = JSON.stringify({
+ nodePath: "/node",
+ platforms: [{ platform: "ios", available: true }],
+ hubPort: 1234,
+ helpers: { serveSimAxSettings: null, serveSimCli: null },
+ ...(mode === "agent-start"
+ ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" }
+ : {}),
+ });
+ }
+ return ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(123),
+ stdout: Stream.make(new TextEncoder().encode(output)),
+ stderr: Stream.empty,
+ all: Stream.empty,
+ exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
+ isRunning: Effect.succeed(forwarding),
+ kill: () => Effect.void,
+ stdin: Sink.drain,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ unref: Effect.succeed(Effect.void),
+ });
+ }),
+ );
+ const host = yield* SshDeviceHost.make(
+ { id: "test", label: "Test", target: "test.example" },
+ () =>
+ rejectConfig
+ ? Effect.fail(
+ new DeviceHost.DeviceHostError({
+ hostId: "test",
+ step: "configuring agent access",
+ cause: new Error("fixture failure"),
+ }),
+ )
+ : Effect.void,
+ ).pipe(
+ Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))),
+ ),
+ ),
+ );
+ yield* host.ensureReady(() => Effect.void);
+ expect(forwards).toBe(1);
+ expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
+ yield* host.platformAvailability("ios");
+ expect((yield* host.summary).hubInstalled).toBe(true);
+ const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result);
+ expect(failed._tag).toBe("Failure");
+ expect(forwards).toBe(0);
+ expect(modes.at(-1)).toBe("stop-agent");
+ expect(yield* host.current).toBeNull();
+ rejectConfig = false;
+ yield* host.ensureAgentReady(() => Effect.void);
+ yield* host.platformAvailability("ios");
+ expect((yield* host.summary).agentDeviceInstalled).toBe(true);
+ yield* host.stopAgent;
+ expect(forwards).toBe(1);
+ yield* host.stop;
+ expect(forwards).toBe(0);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts
new file mode 100644
index 000000000000..4ccda0fdefe9
--- /dev/null
+++ b/apps/server/src/device/SshDeviceHost.ts
@@ -0,0 +1,434 @@
+import * as NodeCrypto from "node:crypto";
+import {
+ type DeviceHostSummary,
+ DevicePlatformAvailability,
+ type SshDeviceHostConfig,
+} from "@t3tools/contracts";
+import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
+import * as NetService from "@t3tools/shared/Net";
+import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
+import * as Exit from "effect/Exit";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Scope from "effect/Scope";
+import * as Semaphore from "effect/Semaphore";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import * as ServerConfig from "../config.ts";
+import * as DeviceHost from "./DeviceHost.ts";
+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
+
+const Probe = Schema.Struct({
+ nodePath: Schema.String,
+ platforms: Schema.Array(DevicePlatformAvailability),
+});
+const Started = Schema.Struct({
+ ...Probe.fields,
+ hubPort: Schema.Int,
+ daemonPort: Schema.optionalKey(Schema.Int),
+ token: Schema.optionalKey(Schema.String),
+ entryPath: Schema.optionalKey(Schema.String),
+ helpers: Schema.Struct({
+ serveSimAxSettings: Schema.NullOr(Schema.String),
+ serveSimCli: Schema.NullOr(Schema.String),
+ }),
+});
+const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe));
+const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started));
+const targetFor = (config: SshDeviceHostConfig) => ({
+ alias: config.target,
+ hostname: config.target,
+ username: null,
+ port: config.port ?? null,
+});
+const identityArgs = (config: SshDeviceHostConfig) =>
+ config.identityFile ? ["-i", config.identityFile] : [];
+const commandArgs = (script: string) => [
+ "sh",
+ "-c",
+ quoteRemoteArg(remoteDeviceEnvironment + script),
+];
+const bootstrap = (
+ config: SshDeviceHostConfig,
+ owner: string,
+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
+) =>
+ runSshCommand(targetFor(config), {
+ preHostArgs: identityArgs(config),
+ remoteCommandArgs: commandArgs(
+ 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node',
+ ),
+ stdin: remoteDeviceScript(owner, mode),
+ timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000,
+ }).pipe(
+ Effect.mapError(
+ (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }),
+ ),
+ );
+
+export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
+ const result = yield* bootstrap(config, "probe", "probe");
+ const value = yield* decodeProbe(result.stdout.trim()).pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }),
+ ),
+ );
+ return {
+ id: config.id,
+ label: config.label,
+ kind: "ssh",
+ hubInstalled: false,
+ agentDeviceInstalled: false,
+ platforms: value.platforms,
+ } satisfies DeviceHostSummary;
+});
+
+export const make = Effect.fn("SshDeviceHost.make")(function* (
+ config: SshDeviceHostConfig,
+ onReady: (
+ ready: DeviceHost.DeviceHostAgentReady,
+ ) => Effect.Effect = () => Effect.void,
+ onStatus: (
+ status: "starting" | "ready" | "failed",
+ detail?: string,
+ ) => Effect.Effect = () => Effect.void,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const server = yield* ServerConfig.ServerConfig;
+ const net = yield* NetService.NetService;
+ const http = yield* HttpClient.HttpClient;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const parentScope = yield* Scope.Scope;
+ const ssh = yield* resolveSshCommand;
+ const environmentId = yield* fs
+ .readFileString(server.environmentIdPath)
+ .pipe(Effect.orElseSucceed(() => server.stateDir));
+ const owner = NodeCrypto.createHash("sha256")
+ .update(`${environmentId}\0${server.stateDir}\0${config.id}`)
+ .digest("hex")
+ .slice(0, 24);
+ const provide = (
+ effect: Effect.Effect<
+ A,
+ E,
+ FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+ >,
+ ) =>
+ effect.pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+ const lock = yield* Semaphore.make(1);
+ let stopped = false;
+ let activated = false;
+ let wantsAgent = false;
+ let ready:
+ | (DeviceHost.DeviceHostReady & {
+ agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"];
+ })
+ | null = null;
+ let connectionScope: Scope.Closeable | null = null;
+ let summary: DeviceHostSummary = {
+ id: config.id,
+ label: config.label,
+ kind: "ssh",
+ hubInstalled: false,
+ agentDeviceInstalled: false,
+ platforms: [],
+ };
+
+ const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
+ provide(
+ runSshCommand(targetFor(config), {
+ preHostArgs: identityArgs(config),
+ remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`),
+ ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
+ ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
+ }),
+ ).pipe(
+ Effect.map((result) => ({ ...result, code: 0 })),
+ Effect.catch((error) =>
+ Effect.succeed({
+ stdout: "stdout" in error ? (error.stdout ?? "") : "",
+ stderr: error.message,
+ code: "exitCode" in error ? (error.exitCode ?? 127) : 127,
+ }),
+ ),
+ );
+
+ const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return<
+ DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] },
+ DeviceHost.DeviceHostError
+ > {
+ activated = true;
+ const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start"));
+ yield* onStatus("starting");
+ const remote = yield* decodeStarted(result.stdout.trim()).pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reading host endpoints",
+ cause,
+ }),
+ ),
+ );
+ summary = {
+ ...summary,
+ platforms: remote.platforms,
+ hubInstalled: true,
+ agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
+ };
+ const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reserving hub port",
+ cause,
+ }),
+ ),
+ );
+ const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reserving daemon port",
+ cause,
+ }),
+ ),
+ );
+ const scope = yield* Scope.make();
+ connectionScope = scope;
+ const child = yield* spawner
+ .spawn(
+ ChildProcess.make(
+ ssh,
+ [
+ ...baseSshArgs(targetFor(config), { batchMode: "yes" }),
+ ...identityArgs(config),
+ "-o",
+ "ExitOnForwardFailure=yes",
+ "-o",
+ "ServerAliveInterval=10",
+ "-o",
+ "ServerAliveCountMax=3",
+ "-N",
+ "-L",
+ `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`,
+ ...(remote.daemonPort === undefined
+ ? []
+ : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]),
+ config.target,
+ ],
+ { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
+ ),
+ )
+ .pipe(
+ Effect.provideService(Scope.Scope, scope),
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }),
+ ),
+ );
+ let stderr = "";
+ yield* child.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runForEach((chunk) =>
+ Effect.sync(() => {
+ stderr = (stderr + chunk).slice(-2000);
+ }),
+ ),
+ Effect.forkIn(scope),
+ );
+ const next = {
+ nodePath: remote.nodePath,
+ hub: { origin: `http://127.0.0.1:${hubPort}` },
+ ...(remote.daemonPort !== undefined &&
+ remote.token !== undefined &&
+ remote.entryPath !== undefined
+ ? {
+ agentDevice: {
+ baseUrl: `http://127.0.0.1:${daemonPort}`,
+ token: remote.token,
+ entryPath: remote.entryPath,
+ },
+ }
+ : {}),
+ helpers: remote.helpers,
+ run,
+ };
+ for (const [baseUrl, route] of [
+ [next.hub.origin, "/readyz"],
+ ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []),
+ ]) {
+ yield* waitForHttpReady({
+ baseUrl: baseUrl!,
+ path: route!,
+ timeoutMs: 15000,
+ makeError: () =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "waiting for SSH forward",
+ cause: new Error(stderr || "Forwarded endpoint did not answer."),
+ }),
+ }).pipe(Effect.provideService(HttpClient.HttpClient, http));
+ }
+ if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice });
+ ready = next;
+ yield* onStatus("ready");
+ // Reconnect also repairs helpers that died while SSH itself stayed connected.
+ const unhealthy = Effect.gen(function* () {
+ while (true) {
+ yield* Effect.sleep("10 seconds");
+ const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe(
+ Effect.timeout("5 seconds"),
+ Effect.map((r) => r.status === 200),
+ Effect.orElseSucceed(() => false),
+ );
+ const daemonAlive = next.agentDevice
+ ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe(
+ Effect.timeout("5 seconds"),
+ Effect.map((r) => r.status === 200),
+ Effect.orElseSucceed(() => false),
+ )
+ : true;
+ if (!alive || !daemonAlive) return;
+ }
+ });
+ yield* Effect.gen(function* () {
+ yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy);
+ if (stopped || connectionScope !== scope) return;
+ ready = null;
+ yield* onStatus("starting", "Reconnecting to device host…");
+ yield* Scope.close(scope, Exit.void);
+ let delay = 1000;
+ while (true) {
+ if (stopped || connectionScope !== scope) return;
+ yield* Effect.sleep(delay);
+ const result = yield* lock
+ .withPermit(
+ Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))),
+ )
+ .pipe(Effect.result);
+ if (result._tag === "Success") return;
+ yield* onStatus("failed", result.failure.message);
+ if (connectionScope && connectionScope !== scope)
+ yield* Scope.close(connectionScope, Exit.void);
+ connectionScope = scope;
+ delay = Math.min(delay * 2, 30000);
+ }
+ }).pipe(Effect.forkIn(parentScope));
+ return next;
+ });
+
+ const connect = Effect.fn("SshDeviceHost.connect")(function* () {
+ for (let attempt = 0; ; attempt++) {
+ const result = yield* connectOnce().pipe(Effect.result);
+ if (result._tag === "Success") return result.success;
+ const failedScope = connectionScope;
+ connectionScope = null;
+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
+ // SSH binds after the reservation is released, so a competing bind needs fresh ports.
+ if (
+ attempt >= 2 ||
+ !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step)
+ )
+ return yield* result.failure;
+ }
+ });
+
+ const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
+ lock.withPermit(
+ Effect.gen(function* () {
+ stopped = false;
+ if (ready) return ready;
+ summary = yield* provide(probe(config));
+ yield* onPhase("installing");
+ return yield* connect().pipe(
+ Effect.tapError(() =>
+ connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,
+ ),
+ );
+ }),
+ );
+ const stop = lock.withPermit(
+ Effect.gen(function* () {
+ stopped = true;
+ ready = null;
+ if (connectionScope) yield* Scope.close(connectionScope, Exit.void);
+ connectionScope = null;
+ if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore);
+ activated = false;
+ wantsAgent = false;
+ }),
+ );
+ const changeAgent = (enabled: boolean) =>
+ lock.withPermit(
+ Effect.gen(function* () {
+ wantsAgent = enabled;
+ if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice };
+ if (!enabled && !ready?.agentDevice) return null;
+ ready = null;
+ const previousScope = connectionScope;
+ connectionScope = null;
+ if (previousScope) yield* Scope.close(previousScope, Exit.void);
+ if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent"));
+ return yield* connect().pipe(
+ Effect.onError(() =>
+ Effect.gen(function* () {
+ const failedScope = connectionScope;
+ connectionScope = null;
+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
+ if (enabled)
+ yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore);
+ }),
+ ),
+ );
+ }),
+ );
+ yield* Effect.addFinalizer(() => stop);
+ return {
+ id: config.id,
+ summary: Effect.sync(() => summary),
+ current: Effect.sync(() => ready),
+ ensureReady,
+ ensureAgentReady: (onPhase) =>
+ onPhase("installing").pipe(
+ Effect.flatMap(() => changeAgent(true)),
+ Effect.flatMap((value) =>
+ value?.agentDevice
+ ? Effect.succeed({ ...value, agentDevice: value.agentDevice })
+ : Effect.fail(
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "starting agent tools",
+ cause: new Error("Daemon endpoint missing"),
+ }),
+ ),
+ ),
+ ),
+ stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
+ stop,
+ platformAvailability: (platform) =>
+ provide(probe(config)).pipe(
+ Effect.map((value) => {
+ summary = { ...summary, platforms: value.platforms };
+ return value.platforms.find((p) => p.platform === platform)!;
+ }),
+ Effect.orElseSucceed(() => ({
+ platform,
+ available: false,
+ reason: "Cannot reach device host. Test its SSH connection in Settings.",
+ })),
+ ),
+ } satisfies DeviceHost.DeviceHost["Service"];
+});
diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts
new file mode 100644
index 000000000000..eadd85fd047d
--- /dev/null
+++ b/apps/server/src/device/sshDeviceScript.test.ts
@@ -0,0 +1,220 @@
+// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes.
+import * as Effect from "effect/Effect";
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { describe, expect, it } from "@effect/vitest";
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeUtil from "node:util";
+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
+
+const exec = NodeUtil.promisify(NodeChildProcess.execFile);
+
+it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-"));
+ try {
+ const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr");
+ await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true });
+ await NodeFSP.writeFile(
+ NodePath.join(javaHome, "bin/java"),
+ "#!/bin/sh\necho test-java\n",
+ { mode: 0o755 },
+ );
+ const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], {
+ env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" },
+ });
+ expect(result.stdout.trim()).toBe("test-java");
+ } finally {
+ await NodeFSP.rm(home, { recursive: true, force: true });
+ }
+ });
+ }),
+);
+
+it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line";
+ const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]);
+ expect(result.stdout).toBe(value);
+ });
+ }),
+);
+
+describe("remote helper lifecycle", () => {
+ it.effect("reuses its own healthy helpers and stops only its own runtime", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-"));
+ const bin = NodePath.join(home, "bin");
+ await NodeFSP.mkdir(bin);
+ await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
+ const root = NodePath.join(home, ".t3/device");
+ const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`);
+ const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`);
+ const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs");
+ const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs");
+ await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), {
+ recursive: true,
+ });
+ await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), {
+ recursive: true,
+ });
+ await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION);
+ await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION);
+ await NodeFSP.writeFile(
+ hub,
+ `import http from 'node:http'; import fs from 'node:fs';
+if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
+const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`,
+ );
+ await NodeFSP.writeFile(
+ agent,
+ `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process';
+const args=process.argv.slice(2);
+const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
+const file=path.join(state,'daemon.json');
+if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} }
+else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); }
+else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); }
+`,
+ );
+ const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
+ const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
+ let invocation = 0;
+ const invoke = async (
+ owner: string,
+ mode: "start" | "agent-start" | "stop-agent" | "stop",
+ upgraded = false,
+ ) => {
+ const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
+ await NodeFSP.writeFile(
+ file,
+ `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` +
+ remoteDeviceScript(owner, mode)
+ .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION)
+ .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION),
+ );
+ const result = await exec(process.execPath, [file], {
+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
+ });
+ return result.stdout ? JSON.parse(result.stdout) : null;
+ };
+ const template = NodePath.join(home, "hub-template");
+ await NodeFSP.cp(hubDir, template, { recursive: true });
+ await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
+ const installLock = hubDir + ".lock";
+ await NodeFSP.symlink("2147483647:exited-installer", installLock);
+ await NodeFSP.writeFile(
+ NodePath.join(bin, "npm"),
+ `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
+ { mode: 0o755 },
+ );
+ await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
+ await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), "");
+ try {
+ const [manual, concurrent] = await Promise.all([
+ invoke("one", "start"),
+ invoke("one", "start"),
+ ]);
+ expect(concurrent.hubPort).toBe(manual.hubPort);
+ expect(manual.daemonPort).toBeUndefined();
+ await expect(
+ NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")),
+ ).rejects.toThrow();
+ const [first, concurrentAgent] = await Promise.all([
+ invoke("one", "agent-start"),
+ invoke("one", "agent-start"),
+ ]);
+ expect(concurrentAgent.hubPort).toBe(first.hubPort);
+ expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
+ const second = await invoke("two", "agent-start");
+ const reused = await invoke("one", "agent-start");
+ expect(reused.hubPort).toBe(first.hubPort);
+ expect(reused.daemonPort).toBe(first.daemonPort);
+ expect(second.hubPort).not.toBe(first.hubPort);
+ expect(second.daemonPort).not.toBe(first.daemonPort);
+ const firstHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
+ );
+ const secondHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"),
+ );
+ await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), "");
+ let repaired = await invoke("one", "agent-start");
+ expect(repaired.hubPort).not.toBe(first.hubPort);
+ const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8"))
+ .trim()
+ .split("\n");
+ expect(stopped).toContain(String(firstHub.pid));
+ expect(stopped).not.toContain(String(secondHub.pid));
+ const previousDaemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ for (const [source, name, version] of [
+ [hubDir, "expo-device-hub", nextHubVersion],
+ [agentDir, "agent-device", nextAgentVersion],
+ ]) {
+ const destination = NodePath.join(root, `tools/${name}@${version}`);
+ await NodeFSP.cp(source!, destination, { recursive: true });
+ await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!);
+ }
+ const upgraded = await invoke("one", "agent-start", true);
+ expect(upgraded.entryPath).toContain(nextAgentVersion);
+ const upgradedHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
+ );
+ expect(upgradedHub.entryPath).toContain(nextHubVersion);
+ const upgradedDaemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid);
+ expect(await invoke("one", "agent-start", true)).toEqual(upgraded);
+ await NodeFSP.writeFile(
+ NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`),
+ "",
+ );
+ repaired = await invoke("one", "agent-start", true);
+ expect(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
+ ).toBe(String(upgradedDaemon.pid));
+ expect(repaired.daemonPort).not.toBe(upgraded.daemonPort);
+ // Stop still uses the recorded entry when a future pinned package is not installed yet.
+ const originalScript = remoteDeviceScript("one", "stop-agent");
+ const upgradedStop = NodePath.join(home, "upgraded-stop.cjs");
+ await NodeFSP.writeFile(
+ upgradedStop,
+ originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"),
+ );
+ await exec(process.execPath, [upgradedStop], {
+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
+ });
+ const daemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ expect(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
+ ).toBe(String(daemon.pid));
+ expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true);
+ await invoke("one", "stop");
+ expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true);
+ expect(
+ JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"))
+ .owner,
+ ).toBe("two");
+ } finally {
+ await invoke("one", "stop").catch(() => {});
+ await invoke("two", "stop").catch(() => {});
+ await NodeFSP.rm(home, { recursive: true, force: true });
+ }
+ });
+ }),
+ );
+});
diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts
new file mode 100644
index 000000000000..bbdb828c1a74
--- /dev/null
+++ b/apps/server/src/device/sshDeviceScript.ts
@@ -0,0 +1,192 @@
+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
+
+export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
+
+/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */
+export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
+if [ -z "$ANDROID_HOME" ]; then
+ if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk";
+ elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi
+fi
+if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi
+if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then
+ for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do
+ if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi
+ done
+fi
+if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi
+`;
+
+/** Node runs this on the host. All paths it returns belong to that host. */
+export const remoteDeviceScript = (
+ owner: string,
+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
+) =>
+ `
+const owner = ${JSON.stringify(owner)};
+const mode = ${JSON.stringify(mode)};
+const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
+const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)};
+` +
+ String.raw`
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const net = require('node:net');
+const { spawn, spawnSync } = require('node:child_process');
+const root = path.join(os.homedir(), '.t3', 'device');
+const state = path.join(root, 'hosts', owner);
+const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
+const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
+const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
+const stopHub = hub => {
+ if (!hub || hub.owner !== owner) return;
+ const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
+ if (command.includes(hub.entryPath) && command.includes(String(hub.port))) {
+ try { process.kill(hub.pid, 'SIGTERM'); } catch {}
+ }
+};
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } };
+const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); });
+async function acquireLock(lock, complete = () => false) {
+ const deadline = Date.now() + 600000;
+ const token = process.pid + ':' + require('node:crypto').randomUUID();
+ const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
+ while (true) {
+ try {
+ // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner.
+ fs.symlinkSync(token, lock);
+ return () => { if (owner() === token) fs.unlinkSync(lock); };
+ } catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ if (complete()) return null;
+ const previous = owner();
+ if (previous === null) continue;
+ const pid = Number(previous.split(':')[0]);
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock);
+ try { process.kill(pid, 0); } catch (error) {
+ if (error.code === 'ESRCH' && owner() === previous) {
+ try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
+ continue;
+ }
+ }
+ if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.');
+ await sleep(500);
+ }
+ }
+}
+async function install(name, version, entry) {
+ const dir = path.join(root, 'tools', name + '@' + version);
+ const file = path.join(dir, 'node_modules', name, entry);
+ const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version;
+ if (complete()) return file;
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
+ const lock = dir + '.lock';
+ const release = await acquireLock(lock, complete);
+ if (!release) return file;
+ let staging;
+ try {
+ if (complete()) return file;
+ staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-'));
+ const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
+ if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000)));
+ if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name);
+ fs.writeFileSync(path.join(staging, '.install-complete'), version);
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.renameSync(staging, dir);
+ return file;
+ } finally {
+ if (staging) fs.rmSync(staging, { recursive: true, force: true });
+ release();
+ }
+}
+(async () => {
+ const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0;
+ const android = run('adb', ['version']).status === 0;
+ const platforms = [
+ { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) },
+ { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) },
+ ];
+ if (mode === 'probe') {
+ if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
+ if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.');
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return;
+ }
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
+ // Serialize starts and stops for this environment/host owner, including agent startup.
+ const hostLock = path.join(state, 'runtime.lock');
+ const releaseHost = await acquireLock(hostLock);
+ try {
+ const hubFile = path.join(state, 'hub.json');
+ const daemonFile = path.join(state, 'daemon.json');
+ const agentFile = path.join(state, 'agent.json');
+ if (mode === 'stop' || mode === 'stop-agent') {
+ const hub = read(hubFile);
+ if (mode === 'stop' && hub && hub.owner === owner) {
+ stopHub(hub);
+ fs.rmSync(hubFile, { force: true });
+ }
+ const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs');
+ if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]);
+ return;
+ }
+ if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' '));
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
+ const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs');
+ let hub = read(hubFile);
+ if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) {
+ stopHub(hub);
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const hubPort = await port();
+ const log = fs.openSync(path.join(state, 'hub.log'), 'a');
+ const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], {
+ cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
+ });
+ try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); }
+ finally { fs.closeSync(log); }
+ child.unref();
+ hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry };
+ write(hubFile, hub);
+ const deadline = Date.now() + 30000;
+ let listening = false;
+ while (child.exitCode === null && child.signalCode === null) {
+ if (await healthy(hub.port, '/readyz')) { listening = true; break; }
+ if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); }
+ await sleep(200);
+ }
+ if (listening) break;
+ // Port reservation and binding happen in different processes. Retry an early exit with a fresh port.
+ fs.rmSync(hubFile, { force: true });
+ if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log'));
+ }
+ }
+ let agentResult = {};
+ if (mode === 'agent-start') {
+ const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs');
+ const previousAgent = read(agentFile)?.entryPath;
+ let daemon = read(daemonFile);
+ if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) {
+ const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]);
+ if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.');
+ fs.rmSync(daemonFile, { force: true });
+ daemon = null;
+ }
+ if (!daemon) {
+ fs.rmSync(daemonFile, { force: true });
+ const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' };
+ delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG;
+ run(process.execPath, [agentEntry, 'devices', '--json'], { env });
+ daemon = read(daemonFile);
+ }
+ if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state);
+ write(agentFile, { entryPath: agentEntry });
+ agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry };
+ }
+ const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
+ const optional = file => fs.existsSync(file) ? file : null;
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult,
+ helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
+ } finally { releaseHost(); }
+})().catch(error => { console.error(error.message); process.exitCode = 1; });
+`;
diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts
index 24097a92faac..3a9307314c39 100644
--- a/apps/server/src/mcp/McpDeviceToolkit.test.ts
+++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts
@@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
screenshot: () => Effect.succeed({ device, png }),
close: () => Effect.void,
agentCli: Effect.succeed("/cli"),
+ testHost: () => Effect.die("not used"),
agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
});
diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts
index 3bc89d22bc60..feb69ec630d6 100644
--- a/apps/server/src/mcp/toolkits/device/handlers.ts
+++ b/apps/server/src/mcp/toolkits/device/handlers.ts
@@ -134,6 +134,9 @@ const handlers = {
.filter((session) => session.threadId === scope.threadId)
.map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
return {
+ hostStatuses: Object.fromEntries(
+ Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId),
+ ),
hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
devices: hostId
? state.devices.filter((device) => device.hostId === hostId)
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 71a8ed19d905..f59a753d9c72 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -2770,6 +2770,10 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {
"rpc.aggregate": "device",
}),
+ [WS_METHODS.deviceTestHost]: (input) =>
+ observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
+ "rpc.aggregate": "device",
+ }),
[WS_METHODS.deviceList]: (_input) =>
observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
"rpc.aggregate": "device",
diff --git a/apps/web/src/components/device/DeviceHostAvailability.tsx b/apps/web/src/components/device/DeviceHostAvailability.tsx
new file mode 100644
index 000000000000..03a0b175dae8
--- /dev/null
+++ b/apps/web/src/components/device/DeviceHostAvailability.tsx
@@ -0,0 +1,27 @@
+import type { DevicePlatformAvailability } from "@t3tools/contracts";
+import { Check, Minus } from "lucide-react";
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
+
+export function DeviceHostAvailability({
+ platforms,
+}: {
+ platforms: ReadonlyArray;
+}) {
+ return (
+
+ {platforms.map((platform) => (
+
+ }>
+ {platform.available ? : }
+ {platform.platform === "ios" ? "iOS" : "Android"}{" "}
+ {platform.available ? "available" : "unavailable"}
+
+
+ {platform.reason ??
+ (platform.platform === "ios" ? "iOS available" : "Android available")}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx
new file mode 100644
index 000000000000..e61cf742dfd5
--- /dev/null
+++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx
@@ -0,0 +1,334 @@
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
+import { AppleIcon, AndroidIcon } from "../Icons";
+import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
+import { Spinner } from "../ui/spinner";
+import type {
+ DevicePlatformAvailability,
+ EnvironmentId,
+ SshDeviceHostConfig,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { randomUUID } from "../../lib/utils";
+import { useState } from "react";
+import { deviceEnvironment, useDeviceState } from "../../state/device";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { MoreVertical, PlusIcon } from "lucide-react";
+import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
+import { SettingsRow } from "./settingsLayout";
+
+/** Host names and identity paths belong to the selected environment, never all environments. */
+export function DeviceHostsSettings(props: {
+ environmentId: EnvironmentId | null;
+ hosts: ReadonlyArray;
+}) {
+ const update = useAtomCommand(serverEnvironment.updateSettings);
+ const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
+ const { state } = useDeviceState(props.environmentId);
+ const [editing, setEditing] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const validPort = (port: number | undefined) =>
+ port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
+ const [checks, setChecks] = useState<
+ Record<
+ string,
+ { pending?: boolean; platforms?: ReadonlyArray; error?: string }
+ >
+ >({});
+ const setCheck = (id: string, value: (typeof checks)[string]) =>
+ setChecks((current) => ({ ...current, [id]: value }));
+ const save = async (hosts: ReadonlyArray) => {
+ if (!props.environmentId) return;
+ setBusy(true);
+ try {
+ const saved = await update({
+ environmentId: props.environmentId,
+ input: { patch: { deviceHosts: hosts } },
+ });
+ if (saved._tag === "Success") {
+ setEditing(null);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+ const testConnection = async (host: SshDeviceHostConfig) => {
+ if (!props.environmentId || checks[host.id]?.pending) return;
+ setCheck(host.id, { pending: true });
+ try {
+ const summary = await test({ environmentId: props.environmentId, input: host });
+ setCheck(
+ host.id,
+ summary._tag === "Failure"
+ ? { error: Cause.pretty(summary.cause) }
+ : { platforms: summary.value.platforms },
+ );
+ } catch (error) {
+ setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
+ }
+ };
+ return (
+ {
+ setEditing({ id: randomUUID(), label: "", target: "" });
+ }}
+ >
+ Add host
+
+ }
+ >
+
+ {!props.environmentId ? (
+
+ Select one connected environment to manage its device hosts.
+
+ ) : (
+ <>
+ {props.hosts.map((host) => {
+ const status = state.hostStatuses[host.id];
+ const check = checks[host.id];
+ const platforms =
+ check?.platforms ??
+ state.hosts.find((value) => value.id === host.id)?.platforms ??
+ [];
+ const progress = check?.pending
+ ? "Checking connection…"
+ : status?.status === "installing"
+ ? "Installing device support…"
+ : status?.status === "starting"
+ ? "Connecting…"
+ : null;
+ const error =
+ check?.error ?? (status?.status === "failed" ? status.detail : undefined);
+ return (
+
+
+
+
{host.label}
+ {platforms
+ .filter((platform) => platform.available)
+ .map((platform) => (
+
+
+ }
+ >
+ {platform.platform === "ios" ? (
+
+ ) : (
+
+ )}
+
+
+ {platform.platform === "ios" ? "iOS available" : "Android available"}
+
+
+ ))}
+
+
{host.target}
+ {error ? (
+
+
+ Connection failed
+ {error}
+
+
+ ) : null}
+
+ {progress ? (
+
+
+ {progress}
+
+ ) : null}
+
+
+ }
+ >
+
+
+
+ {
+ setEditing(host);
+ }}
+ >
+ Edit
+
+
+ void save(props.hosts.filter((value) => value.id !== host.id))
+ }
+ >
+ Remove
+
+
+
+
void testConnection(host)}
+ >
+ Test connection
+
+
+ );
+ })}
+ {editing ? (
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index a3f76f49cd5f..8cb21532a426 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.tsx
@@ -1,3 +1,4 @@
+import { DeviceHostsSettings } from "./DeviceHostsSettings";
/**
* Integrations settings - preferences for surfaces T3 Code embeds rather than
* owns. Browser is the first section: the defaults a preview tab opens at,
@@ -12,6 +13,7 @@ import {
type BrowserLinkTarget,
type BrowserProfile,
type EnvironmentId,
+ type SshDeviceHostConfig,
BROWSER_PROFILE_NAME_MAX_LENGTH,
BROWSER_RECORDING_FRAME_RATES,
DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
@@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() {
function DeviceIntegrationSettings() {
const primaryEnvironment = usePrimaryEnvironment();
- const environmentId = primaryEnvironment?.environmentId ?? null;
+ const { environments } = useEnvironments();
+ const [selectedId, setSelectedId] = useState(null);
+ const selected =
+ environments.find((environment) => environment.environmentId === selectedId) ??
+ environments.find(
+ (environment) => environment.environmentId === primaryEnvironment?.environmentId,
+ ) ??
+ environments[0];
+ const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
+ const environmentId = connected ? selected.environmentId : null;
+
+ return (
+
+ {environments.length > 1 ? (
+ setSelectedId(value)}
+ >
+
+ {selected?.label ?? "Select environment"}
+
+
+ {environments.map((environment) => (
+
+ {environment.label}
+ {environment.connection.phase === "connected" ? "" : " · Offline"}
+
+ ))}
+
+
+ }
+ />
+ ) : null}
+
+
+ );
+}
+
+function DeviceIntegrationControls({
+ environmentId,
+ hosts,
+ enabled,
+ agentAccessEnabled,
+}: {
+ environmentId: EnvironmentId | null;
+ hosts: ReadonlyArray;
+ enabled: boolean;
+ agentAccessEnabled: boolean;
+}) {
const { state, loaded } = useDeviceState(environmentId);
const configure = useAtomCommand(deviceEnvironment.configure);
const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
- const enabled = state.hostStatus !== "disabled";
const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
const [platformsRevealed, setPlatformsRevealed] = useState(false);
// Keep diagnostics visible through subsequent agent setup and refresh phases.
if (platformsRevealed && !enabled) setPlatformsRevealed(false);
- if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
+ if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
setPlatformsRevealed(true);
}
@@ -615,7 +674,7 @@ function DeviceIntegrationSettings() {
};
return (
-
+ <>
{pending === "agent" ? : null}
@@ -689,7 +748,8 @@ function DeviceIntegrationSettings() {
{state.hostStatusDetail}
) : null}
-
+
+ >
);
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 24892b0e1e3b..f56579990714 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/projects",
searchTerms: ["allow open drive preview tools sessions"],
},
+ {
+ id: "device-hosts",
+ title: "Device hosts",
+ to: "/settings/integrations",
+ searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"],
+ },
{
id: "agent-device-access",
title: "Agent device access",
diff --git a/docs/internals/devices.md b/docs/internals/devices.md
index 49027022fdbc..9b34851439ca 100644
--- a/docs/internals/devices.md
+++ b/docs/internals/devices.md
@@ -3,8 +3,7 @@
The environment server owns simulators and emulators the way it owns
terminals: discovery, streaming, and agent access all run there, and every
client reaches them through the environment connection. This is what makes the
-Device panel work over Tailscale and T3 Connect, and what will let a device
-host on another machine slot in later.
+Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices.
## Two external tools, one seam
@@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down.
Everything platform-specific sits behind
[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the
proxy, and the MCP tools only see a hub origin and an agent-device endpoint.
-An SSH or cloud host would forward those two things to the server and change
-nothing above it.
+SSH hosts forward both endpoints to server loopback. Every proxied request
+also carries the host id; device ids alone are not unique across hosts.
## The hub is never exposed
@@ -57,9 +56,8 @@ screenshot capture and stream tuning.
The `device_*` toolkit is deliberately four tools: list, open, screenshot, and
close. Driving happens through the `agent-device` CLI, which has the semantic
snapshot model agents need and stays current with its own releases. T3 prepends
-a shim directory to the provider's PATH and sets
-`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the
-agent never handles the endpoint or token.
+a shim directory to the provider's PATH. The CLI installs on the environment
+server even when that server cannot run simulators. Hosts start on demand.
That environment is fixed when the provider subprocess spawns, so
[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)
diff --git a/docs/user/devices.md b/docs/user/devices.md
index 51a0a578b607..7d1b91792ae3 100644
--- a/docs/user/devices.md
+++ b/docs/user/devices.md
@@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it.
Choose a running device to watch it, or choose **Start** next to a stopped
device to boot it. The panel shows when you or an agent starts a device.
+Each device opens in its own tab. Use **+ → Device** to open another, and
+double-click a tab name or choose **Rename** from its context menu to rename it.
+Only the visible tab streams video; switching tabs keeps both devices running.
Turn off the device hub in **Settings → Integrations → Devices** to stop the
helper processes; simulators and emulators keep running until you power them
off.
@@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices.
The screen is interactive: click and drag to touch, type while the screen is
focused, and use the toolbar for Home, Back, and Recents on Android, rotate on
iOS, and power off. Close the tab to stop watching; the device keeps running
-unless you power it off.
+unless you power it off. Closed tabs stay closed after a reload. To watch the
+device again, choose it from **+ → Device**.
## Tools
@@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the
local network, Tailscale, and T3 Connect. Live video needs a secure page
(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower
still-image stream and Android cannot show video.
+
+## SSH device hosts
+
+In Settings → Integrations → Devices, select one connected environment
+and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with
+an optional identity file and port. These resolve on the environment server,
+so use the SSH configuration and keys available there. Password prompts are
+not supported.
+
+**Test connection** checks SSH, Node, npm, and platform tools without installing
+anything. The first device listing installs pinned device tools on the host.
+Node 22 or newer and npm must be available to non-interactive SSH commands.
+T3 checks common Homebrew and Android SDK locations; custom installations need
+the appropriate PATH and ANDROID_HOME on the host.
+
+The picker identifies devices by host when several hosts are configured.
+Connections recover after interruptions. Removing a host closes its device
+sessions and stops its T3 helpers when reachable; simulators keep running.
+
+T3 provides discovery, streaming, and control. Arrange app builds,
+installation, and connectivity to development servers such as Metro separately.
+A simulator on another machine cannot reach Metro through your environment's
+localhost without forwarding or another reachable address.
diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts
index df27f793720c..1e6523497ef3 100644
--- a/packages/client-runtime/src/state/device.ts
+++ b/packages/client-runtime/src/state/device.ts
@@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms(
scheduler,
concurrency,
}),
+ testHost: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:device:test-host",
+ tag: WS_METHODS.deviceTestHost,
+ }),
list: createEnvironmentRpcCommand(runtime, {
label: "environment-data:device:list",
tag: WS_METHODS.deviceList,
diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts
index 895a954e2e40..03102dd62b44 100644
--- a/packages/contracts/src/device.ts
+++ b/packages/contracts/src/device.ts
@@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type;
/** The server machine. Always present; other host kinds are future work. */
export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
+/** SSH aliases and key paths are resolved on the environment server. */
+export const SshDeviceHostConfig = Schema.Struct({
+ id: DeviceHostId.check(
+ Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
+ Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."),
+ ),
+ label: TrimmedNonEmptyString,
+ target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)),
+ identityFile: Schema.optional(TrimmedNonEmptyString),
+ port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
+});
+export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
+
+export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(
+ Schema.makeFilter(
+ (hosts) =>
+ new Set(hosts.map((host) => host.id)).size === hosts.length ||
+ "Device host ids must be unique.",
+ ),
+);
+
/** Simulator udid or adb serial (an AVD name while it is not running). */
export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
export type DeviceId = typeof DeviceId.Type;
@@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
export const DeviceHostSummary = Schema.Struct({
id: DeviceHostId,
- kind: Schema.Literals(["local"]),
+ kind: Schema.Literals(["local", "ssh"]),
label: TrimmedNonEmptyString,
platforms: Schema.Array(DevicePlatformAvailability),
hubInstalled: Schema.Boolean,
@@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type;
// panel describe devices the same way.
export const DeviceToolListResult = Schema.Struct({
+ hostStatuses: DeviceServiceState.fields.hostStatuses,
hosts: Schema.Array(DeviceHostSummary),
devices: Schema.Array(DeviceSummary),
/** Devices already open in this thread's Device panel. */
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index a5d35bf2361d..5e11aeb28fa3 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -189,6 +189,8 @@ import {
DeviceDetailInput,
DeviceError,
DeviceListInput,
+ SshDeviceHostConfig,
+ DeviceHostSummary,
DeviceOpenInput,
DeviceServiceState,
DeviceSession,
@@ -328,6 +330,7 @@ export const WS_METHODS = {
// Device methods
deviceConfigure: "device.configure",
deviceList: "device.list",
+ deviceTestHost: "device.testHost",
deviceOpen: "device.open",
deviceClose: "device.close",
deviceShutdown: "device.shutdown",
@@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov
stream: true,
});
+const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, {
+ payload: SshDeviceHostConfig,
+ success: DeviceHostSummary,
+ error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
+});
+
const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {
payload: DeviceListInput,
success: DeviceServiceState,
@@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make(
WsSubscribeDiscoveredLocalServersRpc,
WsDeviceConfigureRpc,
WsDeviceListRpc,
+ WsDeviceTestHostRpc,
WsDeviceOpenRpc,
WsDeviceCloseRpc,
WsDeviceShutdownRpc,
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 8cbf0e55b9df..7322307c1f89 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => {
expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
});
});
+
+const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
+
+it("validates remote device hosts and rejects ambiguous host ids", () => {
+ const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 };
+ expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]);
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow();
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow();
+ expect(() =>
+ decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }),
+ ).toThrow();
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 96516367612c..dd6136461fc1 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1,3 +1,4 @@
+import { SshDeviceHostConfigs } from "./device.ts";
import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Schema from "effect/Schema";
@@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({
enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** Whether the server-local Device panel setup flow has been completed. */
deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
@@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({
enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
+ deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index a5e428fcdaac..cc783fe64bf3 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -21,6 +21,16 @@ import {
} from "./serverSettings.ts";
describe("serverSettings helpers", () => {
+ it("replaces SSH host lists when saving, editing, and removing hosts", () => {
+ const host = { id: "mini", label: "Mac mini", target: "mini" };
+ const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
+ expect(saved.deviceHosts).toEqual([host]);
+ const replacement = { ...host, target: "other-mini" };
+ const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] });
+ expect(edited.deviceHosts).toEqual([replacement]);
+ expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]);
+ });
+
it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
const project = { id: ProjectId.make("project-actions"), scripts: [] };
const action = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c58c455b465..bebd36cec2e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,9 @@ importers:
'@t3tools/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@t3tools/ssh':
+ specifier: workspace:*
+ version: link:../../packages/ssh
'@t3tools/tailscale':
specifier: workspace:*
version: link:../../packages/tailscale