From 1c9701a71f028f524b63a3ae0f2577ebbcce665b Mon Sep 17 00:00:00 2001 From: Joshua Riley Date: Mon, 2 Nov 2026 17:02:29 +0000 Subject: [PATCH 1/4] feat(devices): Local simulators can be turned off as a device source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other device source (SSH, EAS, Simlock) can be added and removed, but this machine's simulators were always on. When a Simlock worker runs on the same Mac, the Device panel and agents saw every model twice and the local hub kept running for devices nobody wanted to use. Add a `localDevicesEnabled` server setting (default on) with a switch on the Local simulators row in Settings → Devices. Off, readiness skips the local host, opening a local device is refused with a pointer to the setting, and the next listing stops the local hub and drops its devices and sessions. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 23 +++++++++ apps/server/src/device/DeviceService.ts | 47 +++++++++++++++++-- .../settings/DeviceSourcesSettings.tsx | 34 +++++++++++++- docs/user/devices.md | 3 +- packages/contracts/src/settings.ts | 3 ++ 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..faa2b1f252723 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -392,6 +392,29 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect( + "turning local simulators off stops the hub, hides its devices, and refuses opens", + () => + Effect.gen(function* () { + const { service, starts, settings } = yield* fixture(); + expect((yield* service.configure({ enabled: true })).devices).toHaveLength(1); + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + const off = yield* service.list; + expect(off.devices).toEqual([]); + expect(off.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined(); + expect(starts).toEqual(["start", "stop"]); + yield* service.list; + expect(starts).toEqual(["start", "stop"]); + expect(yield* service.readinessIfSupported()).toBeNull(); + const open = yield* service.readiness().pipe(Effect.flip); + expect(open).toMatchObject({ reason: expect.stringContaining("Settings → Devices") }); + + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: true })); + expect((yield* service.list).devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(starts).toEqual(["start", "stop", "start"]); + }).pipe(Effect.scoped), + ); + it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => Effect.gen(function* () { const { service, requests } = yield* fixture(); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d91c9d8d0b53b..a339f3bad3513 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -278,6 +278,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* enabled: value.enableDeviceSupport, agentAccessEnabled: value.enableAgentDeviceAccess, onboardingCompleted: value.deviceOnboardingCompleted, + localEnabled: value.localDevicesEnabled, })), Effect.mapError( (cause) => @@ -410,7 +411,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( function* (hostId) { const host = yield* resolveHost(hostId); - if (!(yield* readDeviceSettings).enabled) { + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) { return yield* new DeviceHostUnavailableError({ hostId: host.id, hostLabel: host.label, @@ -418,6 +420,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* "Device support is off. Enable it in the Device panel before installing or starting device tools.", }); } + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "Local simulators are turned off. Turn them on in Settings → Devices.", + }); + } const ready = yield* host .ensureReady((status, detail) => setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), @@ -465,8 +474,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn( "DeviceService.readinessIfSupported", )(function* (hostId) { - if (!(yield* readDeviceSettings).enabled) return null; + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) return null; const host = yield* resolveHost(hostId); + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null; const summary = yield* host.summary; if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; @@ -478,6 +489,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const deviceSettings = yield* readDeviceSettings; if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; const host = yield* resolveHost(hostId); + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null; const summary = yield* host.summary; if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; @@ -582,8 +594,37 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); }); + /** Stops the local hub and forgets what it listed, once, after local simulators are turned off. */ + const retireLocalHost = lifecycleLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + const isLocal = (entry: { readonly hostId: DeviceHostId }) => + entry.hostId === LOCAL_DEVICE_HOST_ID; + if ( + !state.devices.some(isLocal) && + !state.sessions.some(isLocal) && + state.hostStatuses[LOCAL_DEVICE_HOST_ID] === undefined + ) + return; + const local = hosts.get(LOCAL_DEVICE_HOST_ID); + if (local) yield* local.stop; + const { [LOCAL_DEVICE_HOST_ID]: _retired, ...hostStatuses } = state.hostStatuses; + yield* publish((current) => ({ + ...current, + hostStatus: "idle", + hostStatusDetail: undefined, + hostStatuses, + devices: current.devices.filter((device) => !isLocal(device)), + sessions: current.sessions.filter((session) => !isLocal(session)), + bootingDevices: current.bootingDevices?.filter((device) => !isLocal(device)), + })); + }), + ); + const list: DeviceService["Service"]["list"] = Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) return (yield* SynchronizedRef.get(stateRef)).state; + if (!deviceSettings.localEnabled) yield* retireLocalHost; yield* Effect.forEach( hosts.values(), (host) => diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 09ccc32fd61b9..24eb8e07db7d7 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -27,6 +27,7 @@ import { Button, InlineButton } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { DeviceHostEditor } from "./DeviceHostEditor"; import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; @@ -351,15 +352,44 @@ export function DeviceSourcesSettings(props: { const rows: SourceRow[] = []; + const localEnabled = (member: (typeof members)[number]) => + member.settings?.localDevicesEnabled !== false; + const anyLocalEnabled = active.some(localEnabled); + const setLocalEnabled = async (enabled: boolean) => { + const saved = await write( + active.map((member) => ({ + environmentId: member.environmentId, + patch: { localDevicesEnabled: enabled }, + })), + enabled + ? "Local simulators not turned on everywhere" + : "Local simulators not turned off everywhere", + ); + // Listing again stops or starts the local hub now instead of on the next panel open. + if (saved) + for (const member of active) void list({ environmentId: member.environmentId, input: {} }); + }; + rows.push({ key: "local", shared: false, title: "Local simulators", - description: - members.length > 1 + description: !anyLocalEnabled + ? "Off. Simulators and emulators on this machine stay out of the Device panel and agent tools." + : members.length > 1 ? "Simulators and emulators installed on each environment's own machine." : "Simulators and emulators installed on this environment's machine.", + actions: ( + void setLocalEnabled(Boolean(checked))} + aria-label="Local simulators" + /> + ), environments: active.map((member) => { + if (!localEnabled(member)) + return { environmentId: member.environmentId, label: member.label, added: false }; const local = member.state?.hosts.find((host) => host.kind === "local"); const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); return { diff --git a/docs/user/devices.md b/docs/user/devices.md index 46a5a427cc87c..b697f00291f9c 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -24,7 +24,8 @@ window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. Turn off **Use devices** in **Settings → Devices** to stop the helper processes; simulators and emulators keep running until you power them -off. +off. To use only SSH, EAS, or Simlock sources, turn off **Local simulators** +under **Device sources**; the panel and agents then skip this machine's devices. Simulators run on the machine that hosts the environment server. iOS needs macOS with Xcode. Android needs the SDK Platform-Tools, Android Emulator, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f291cec115f73..626ee3e517584 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1238,6 +1238,8 @@ export const ServerSettings = Schema.Struct({ /** Enabling the Device panel must not grant providers device control. */ enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Off, the server neither starts nor lists this machine's simulators and emulators. */ + localDevicesEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), @@ -1639,6 +1641,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), + localDevicesEnabled: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs), From 13fca58088108883d42070b41da01162477a83c6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 02:24:26 +0000 Subject: [PATCH 2/4] fix(devices): enforce local source shutdown and explain disabled opens --- apps/server/src/device/DeviceService.test.ts | 63 +++++++++------ apps/server/src/device/DeviceService.ts | 77 ++++++++++++------- apps/server/src/mcp/McpDeviceToolkit.test.ts | 30 ++++++++ .../src/mcp/toolkits/device/handlers.ts | 2 + .../settings/DeviceSourcesSettings.tsx | 5 +- 5 files changed, 121 insertions(+), 56 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index faa2b1f252723..c9180d9bc254d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -100,6 +100,7 @@ const fixture = Effect.fn("fixture")(function* ( const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); + let running = false; let booted = false; let shutDown = false; const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; @@ -242,6 +243,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") return yield* runtimeFailure; + running = true; starts.push("start"); yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; @@ -257,7 +259,7 @@ const fixture = Effect.fn("fixture")(function* ( }; }), current: Effect.sync(() => - connection.online + connection.online && running ? { ...ready, ...(agentStarts.length > agentStops.length @@ -275,6 +277,7 @@ const fixture = Effect.fn("fixture")(function* ( agentStops.push("stop"); }), stop: Effect.sync(() => { + running = false; starts.push("stop"); }), }; @@ -557,30 +560,40 @@ describe("device setup consent", () => { ); }); -it.effect("publishes boot progress and does not restore sessions after support is disabled", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const finish = yield* Deferred.make(); - const { service } = yield* fixture( - Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), - ); - yield* service.configure({ enabled: true }); - const opening = yield* service - .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" }) - .pipe(Effect.result, Effect.forkChild); - yield* Deferred.await(started); - expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ - "Pixel_API_35", - ]); - yield* service.configure({ enabled: false }); - yield* Deferred.succeed(finish, undefined); - expect((yield* Fiber.join(opening))._tag).toBe("Failure"); - const state = yield* service.state; - expect(state.hostStatus).toBe("disabled"); - expect(state.devices).toEqual([]); - expect(state.sessions).toEqual([]); - expect(state.bootingDevices).toEqual([]); - }).pipe(Effect.scoped), +it.effect.each(["all", "local"] as const)( + "does not restore sessions after %s support is disabled", + (disabled) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const { service, settings } = yield* fixture( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), + ); + yield* service.configure({ enabled: true }); + const opening = yield* service + .open({ + threadId: ThreadId.make("thread-1"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(started); + expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ + "Pixel_API_35", + ]); + if (disabled === "all") yield* service.configure({ enabled: false }); + else { + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + yield* service.list; + } + yield* Deferred.succeed(finish, undefined); + expect((yield* Fiber.join(opening))._tag).toBe("Failure"); + const state = yield* service.state; + expect(state.hostStatus).toBe(disabled === "all" ? "disabled" : "idle"); + expect(state.devices).toEqual([]); + expect(state.sessions).toEqual([]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), ); it.effect.each([ diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index a339f3bad3513..0a1a392da867f 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -219,6 +219,7 @@ export class DeviceService extends Context.Service< Option.Option, DeviceHostAmbiguousError | DeviceOnOtherHostError >; + readonly ensureEnabled: (hostId?: DeviceHostId) => Effect.Effect; readonly readiness: (hostId?: DeviceHostId) => Effect.Effect; readonly readinessIfSupported: ( hostId?: DeviceHostId, @@ -408,25 +409,32 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* })), ); + const ensureEnabled: DeviceService["Service"]["ensureEnabled"] = Effect.fn( + "DeviceService.ensureEnabled", + )(function* (hostId) { + const host = yield* resolveHost(hostId); + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: + "Device support is off. Enable it in the Device panel before installing or starting device tools.", + }); + } + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "Local simulators are turned off. Turn them on in Settings → Devices.", + }); + } + }); + const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( function* (hostId) { const host = yield* resolveHost(hostId); - const deviceSettings = yield* readDeviceSettings; - if (!deviceSettings.enabled) { - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: - "Device support is off. Enable it in the Device panel before installing or starting device tools.", - }); - } - if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) { - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: "Local simulators are turned off. Turn them on in Settings → Devices.", - }); - } + yield* ensureEnabled(host.id); const ready = yield* host .ensureReady((status, detail) => setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), @@ -560,7 +568,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) + const deviceSettings = yield* readDeviceSettings; + if ( + !deviceSettings.enabled || + (ready.hostId === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) || + !host || + hosts.get(ready.hostId) !== host + ) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, @@ -600,24 +614,28 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const { state } = yield* SynchronizedRef.get(stateRef); const isLocal = (entry: { readonly hostId: DeviceHostId }) => entry.hostId === LOCAL_DEVICE_HOST_ID; + const local = hosts.get(LOCAL_DEVICE_HOST_ID); if ( + !(local && (yield* local.current)) && + !state.bootingDevices?.some(isLocal) && !state.devices.some(isLocal) && !state.sessions.some(isLocal) && state.hostStatuses[LOCAL_DEVICE_HOST_ID] === undefined ) return; - const local = hosts.get(LOCAL_DEVICE_HOST_ID); if (local) yield* local.stop; - const { [LOCAL_DEVICE_HOST_ID]: _retired, ...hostStatuses } = state.hostStatuses; - yield* publish((current) => ({ - ...current, - hostStatus: "idle", - hostStatusDetail: undefined, - hostStatuses, - devices: current.devices.filter((device) => !isLocal(device)), - sessions: current.sessions.filter((session) => !isLocal(session)), - bootingDevices: current.bootingDevices?.filter((device) => !isLocal(device)), - })); + yield* publish((current) => { + const { [LOCAL_DEVICE_HOST_ID]: _retired, ...hostStatuses } = current.hostStatuses; + return { + ...current, + hostStatus: "idle", + hostStatusDetail: undefined, + hostStatuses, + devices: current.devices.filter((device) => !isLocal(device)), + sessions: current.sessions.filter((session) => !isLocal(session)), + bootingDevices: current.bootingDevices?.filter((device) => !isLocal(device)), + }; + }); }), ); @@ -772,6 +790,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")( function* (input, context) { const host = yield* resolveHost(input.hostId); + yield* ensureEnabled(host.id); yield* ensurePlatform(host, input.platform); const ready = yield* readiness(host.id); let state = yield* refresh(ready); @@ -858,6 +877,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hostLabel: host.label, reason: "Device support was turned off while the device was opening.", }); + yield* ensureEnabled(host.id); yield* publish((current) => ({ ...current, sessions: [ @@ -1226,6 +1246,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* screenshot, locateDevice, locateSession, + ensureEnabled, readiness, readinessIfSupported, agentReadinessIfSupported, diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index fe0cd9fda097e..40fb815c1876a 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -115,6 +115,7 @@ const openSession = { const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(state), list: Effect.succeed(state), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.succeed(readiness), open: (input) => Effect.succeed({ @@ -245,6 +246,7 @@ it.effect("registers the device tools and returns the screenshot as image conten it.effect("rejects unavailable agent access before booting or opening a device", () => { const layerUnavailable = Layer.mock(DeviceService.DeviceService)({ list: Effect.succeed(state), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.fail( new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }), @@ -358,6 +360,7 @@ describe("device targeting across hosts", () => { const layerDevices = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(multiHostState), list: Effect.succeed(multiHostState), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.succeed(readiness), open: (input) => Effect.sync(() => { @@ -524,6 +527,33 @@ describe("device targeting across hosts", () => { ), ); + it.effect("explains disabled local devices before selecting a device", () => + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + for (const input of [{ hostId: "local", platform: "android" }, { platform: "android" }]) { + const result = yield* call("device_open", input); + expect(result.isError).toBe(true); + expect(text(result)).toContain( + "Local simulators are turned off. Turn them on in Settings → Devices.", + ); + } + expect(opened).toEqual([]); + }), + { + list: Effect.succeed({ ...multiHostState, devices: [macIphone] }), + ensureEnabled: () => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: "local", + reason: "Local simulators are turned off. Turn them on in Settings → Devices.", + }), + ), + }, + ), + ); + it.effect("says no host has a device id, naming the host it was asked on", () => run([], ({ opened, call }) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index b73661899c85f..4f271e43fea94 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -228,6 +228,8 @@ const handlers = { }); } const hostId = yield* resolveHostId(state.hosts, input.hostId); + if (hostId !== undefined || input.deviceId === undefined) + yield* devices.ensureEnabled(hostId); const target = yield* pickDevice(state, { ...input, hostId }); // Resolve consent and agent connectivity before booting or registering a session. if (!(yield* devices.agentReadinessIfSupported(target.hostId))) { diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 24eb8e07db7d7..bad00b814dd9e 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -356,7 +356,7 @@ export function DeviceSourcesSettings(props: { member.settings?.localDevicesEnabled !== false; const anyLocalEnabled = active.some(localEnabled); const setLocalEnabled = async (enabled: boolean) => { - const saved = await write( + await write( active.map((member) => ({ environmentId: member.environmentId, patch: { localDevicesEnabled: enabled }, @@ -366,8 +366,7 @@ export function DeviceSourcesSettings(props: { : "Local simulators not turned off everywhere", ); // Listing again stops or starts the local hub now instead of on the next panel open. - if (saved) - for (const member of active) void list({ environmentId: member.environmentId, input: {} }); + for (const member of active) void list({ environmentId: member.environmentId, input: {} }); }; rows.push({ From 7df69f8c7a6f34dbc24d95e4ed9ee45a06f5411f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 02:37:44 +0000 Subject: [PATCH 3/4] fix(devices): ignore listing failures after local source retirement --- apps/server/src/device/DeviceService.test.ts | 41 ++++++++++++++++++-- apps/server/src/device/DeviceService.ts | 16 ++++++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c9180d9bc254d..9e5fdb8ea5390 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -88,6 +88,7 @@ const fixture = Effect.fn("fixture")(function* ( readonly routesStreams?: boolean; readonly onClose?: Effect.Effect<"ok" | "missing" | "reject" | "http_error" | "malformed">; readonly onPlatformAvailability?: Effect.Effect; + readonly onSummary?: Effect.Effect; } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); @@ -229,12 +230,12 @@ const fixture = Effect.fn("fixture")(function* ( kind: connection.kind, label: "Test server", platforms: [ - { platform: "android", available: true }, - { platform: "ios", available: true }, + { platform: "android" as const, available: true }, + { platform: "ios" as const, available: true }, ], hubInstalled: true, agentDeviceInstalled: false, - })), + })).pipe(Effect.tap(() => options.onSummary ?? Effect.void)), platformAvailability: (platform) => (options.onPlatformAvailability ?? Effect.void).pipe( Effect.as({ platform, available: true }), @@ -560,6 +561,40 @@ describe("device setup consent", () => { ); }); +it.effect("does not publish a late local listing failure after the source is disabled", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const finish = yield* Deferred.make(); + let pause = false; + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onSummary: Effect.suspend(() => + pause + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(finish))) + : Effect.void, + ), + }, + ); + yield* service.configure({ enabled: true }); + pause = true; + const listing = yield* service.list.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + yield* service.list; + yield* Deferred.succeed(finish, undefined); + const after = yield* Fiber.join(listing); + expect(after.hostStatus).toBe("idle"); + expect(after.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined(); + expect(after.devices).toEqual([]); + }).pipe(Effect.scoped), +); + it.effect.each(["all", "local"] as const)( "does not restore sessions after %s support is disabled", (disabled) => diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 0a1a392da867f..b8a46dbb6e974 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -651,10 +651,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { - status: "failed", - detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, - }), + lifecycleLock.withPermit( + Effect.gen(function* () { + const current = yield* readDeviceSettings; + if (!current.enabled || (host.id === LOCAL_DEVICE_HOST_ID && !current.localEnabled)) + return; + yield* setHostStatus(host.id, { + status: "failed", + detail: + error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }); + }), + ), ), ), { concurrency: 4 }, From 82f90c87ffa0c4c89fcf42225051b46e29ebebd6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 22:56:09 +0000 Subject: [PATCH 4/4] fix(devices): save local source preference across selected environments --- apps/web/src/components/settings/DeviceSourcesSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index bad00b814dd9e..064af8bf24436 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -357,7 +357,7 @@ export function DeviceSourcesSettings(props: { const anyLocalEnabled = active.some(localEnabled); const setLocalEnabled = async (enabled: boolean) => { await write( - active.map((member) => ({ + members.map((member) => ({ environmentId: member.environmentId, patch: { localDevicesEnabled: enabled }, })),