From 36764387ad52b972443dfe6f4f1642ad596cd9b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 15:37:33 -0700 Subject: [PATCH 1/5] fix(devices): Simlock hosts save without every token and check the gateway right away Saving a Simlock source no longer needs an agent token for every selected environment; a blank one shows "No agent token" with Add token on that environment. The server now asks the gateway as soon as it creates the host, and the platform icons show its answer instead of always being available, with a refused token named as such. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.ts | 8 +++ .../src/device/SimlockDeviceHost.test.ts | 19 ++++- apps/server/src/device/SimlockDeviceHost.ts | 69 +++++++++++++++---- .../settings/SimlockDeviceHostEditor.tsx | 3 +- .../simlockDeviceHostsSettings.logic.test.ts | 7 +- .../simlockDeviceHostsSettings.logic.ts | 3 +- 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 5f821f5bf0217..869b130105bfe 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1379,6 +1379,14 @@ export const make = Effect.gen(function* () { ); hosts.set(host.id, instance); configured.set(host.id, { config: host, scope: hostScope }); + // Ask the gateway now, so a refused token shows in Settings before anyone opens a device. + if ((yield* settings.getSettings).enableDeviceSupport) + yield* instance + .platformAvailability("ios") + .pipe( + Effect.andThen(service.withLifecycleLock(service.refreshHosts)), + Effect.forkIn(hostScope), + ); continue; } if (host.kind === "eas") { diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index f67f7bc8d76c6..11b041d8705f2 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -66,9 +66,10 @@ const fakePool = ( Effect.sync(() => { const url = new URL(request.url); requests.push(`${request.method} ${url.pathname}`); - expect(request.headers.authorization).toBe("Bearer agent-token"); const json = (body: unknown, status = 200) => HttpClientResponse.fromWeb(request, Response.json(body, { status })); + if (request.headers.authorization !== "Bearer agent-token") + return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401); if (url.pathname === "/v1/catalog") return json(CATALOG); if (url.pathname.endsWith("/renew")) return json({}, renewStatus); if (request.method === "DELETE") return json({}, 202); @@ -257,6 +258,22 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("reports its platforms unavailable when the gateway refuses the agent token", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ agentToken: "revoked" }), scope); + expect(yield* host.platformAvailability("ios")).toEqual({ + platform: "ios", + available: false, + reason: + "The Simlock gateway refused the agent token for Device pool. Check it in Settings.", + }); + expect((yield* host.summary).platforms.every((platform) => !platform.available)).toBe(true); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("closes the session of a failed open so no lease is left behind", () => Effect.gen(function* () { const pool = yield* fakePool({ failOpen: true }); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index c2c25f0b7de21..d9d786b7a00ed 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -10,6 +10,7 @@ import { DeviceHostUnavailableError, DeviceOperationError, type DevicePlatform, + type DevicePlatformAvailability, type SimlockDeviceHostConfig, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -22,7 +23,7 @@ import * as Path from "effect/Path"; import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; @@ -114,6 +115,8 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( readonly at: number; readonly devices: ReadonlyArray; } | null = null; + // The last answer from the gateway; empty until it gives one. + let platforms: ReadonlyArray = []; let ready: DeviceHost.DeviceHostReady | null = null; let cli: { readonly nodePath: string; readonly entryPath: string } | null = null; @@ -197,7 +200,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( return result.stdout; }); - const loadCatalog = Effect.gen(function* () { + const fetchCatalog = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices; if (!config.agentToken) { @@ -205,12 +208,19 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( } const decoded = yield* request("GET", "/v1/catalog").pipe( Effect.flatMap(decodeCatalog), - Effect.mapError((cause) => - unavailable( - `Could not reach the Simlock gateway at ${gateway}, or it refused the agent token.`, + Effect.mapError((cause) => { + const status = HttpClientError.isHttpClientError(cause) + ? cause.response?.status + : undefined; + return unavailable( + status === 401 || status === 403 + ? `The Simlock gateway refused the agent token for ${config.label}. Check it in Settings.` + : status !== undefined + ? `The Simlock gateway at ${gateway} answered HTTP ${status}.` + : `Could not reach the Simlock gateway at ${gateway}.`, cause, - ), - ), + ); + }), ); const devices = decoded.platforms.flatMap((entry) => { if (entry.platform !== "ios" && entry.platform !== "android") return []; @@ -236,6 +246,31 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( return devices; }); + const loadCatalog = fetchCatalog.pipe( + Effect.tap((devices) => + Effect.sync(() => { + platforms = (["ios", "android"] as const).map((platform) => + devices.some((device) => device.platform === platform) + ? { platform, available: true } + : { + platform, + available: false, + reason: `The pool has no ${platform === "ios" ? "iOS" : "Android"} devices.`, + }, + ); + }), + ), + Effect.tapError((error) => + Effect.sync(() => { + platforms = (["ios", "android"] as const).map((platform) => ({ + platform, + available: false, + reason: error.reason, + })); + }), + ), + ); + const forget = (lease: Lease) => Effect.suspend(() => { leases.delete(lease.leaseId); @@ -515,19 +550,25 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( yield* Effect.addFinalizer(() => stopAll); const platformAvailability = (platform: DevicePlatform) => - Effect.succeed({ platform, available: true }); + loadCatalog.pipe( + Effect.ignore, + Effect.map( + (): DevicePlatformAvailability => + platforms.find((candidate) => candidate.platform === platform) ?? { + platform, + available: false, + }, + ), + ); - const summary: Effect.Effect = Effect.succeed({ + const summary: Effect.Effect = Effect.sync(() => ({ id: hostId, label: config.label, kind: "simlock" as const, - platforms: [ - { platform: "ios" as const, available: true }, - { platform: "android" as const, available: true }, - ], + platforms, hubInstalled: true, agentDeviceInstalled: true, - }); + })); return DeviceHost.DeviceHost.of({ id: hostId, diff --git a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx index 7564d86e67a25..b9e611f8a9f0e 100644 --- a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx +++ b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx @@ -142,7 +142,8 @@ export function SimlockDeviceHostEditor(props: { ))}

Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} - with simlock --gateway token create --role agent. + with simlock --gateway token create --role agent + {several ? ", or leave an environment blank to add its token later" : ""}.

diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts index 752ab616bdb58..2efe3c78c24eb 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -36,7 +36,7 @@ describe("Simlock device host settings", () => { expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); }); - it("saves the shared fields everywhere and each environment's own token", () => { + it("saves the shared fields everywhere and each environment's own token, if it has one", () => { const mbp = "mbp" as EnvironmentId; const cups = "cups" as EnvironmentId; const environments = [ @@ -48,7 +48,10 @@ describe("Simlock device host settings", () => { expect( planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), ).toEqual({ - problem: "Add an agent token for cups.", + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "" }] }, + ], }); expect( planSimlockDeviceHostSave( diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts index 3a2f65b1d9065..a4ef1e1bb90b1 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -62,7 +62,7 @@ export type SimlockTokenEntry = /** * The host list each environment saves: the shared fields everywhere, and each environment's own - * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + * token, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it. */ export function planSimlockDeviceHostSave( environments: ReadonlyArray, @@ -84,7 +84,6 @@ export function planSimlockDeviceHostSave( const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; const agentToken = entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; - if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; patches.push({ environmentId: environment.environmentId, hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), From 172756fbd0584fd34e6f5f9fbd1d3f9cc672b212 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:35:00 -0700 Subject: [PATCH 2/5] feat(devices): Simlock hosts find this machine's agent-device gateway and token, and check each token as you enter it Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/device/DeviceService.ts | 66 +++++++++++++ .../src/device/SimlockDeviceHost.test.ts | 32 ++++++ apps/server/src/device/SimlockDeviceHost.ts | 98 +++++++++++++++---- .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/ws.ts | 1 + .../settings/DeviceSourcesSettings.tsx | 10 +- .../settings/SimlockDeviceHostEditor.tsx | 88 ++++++++++++++++- apps/web/src/state/device.ts | 11 +++ packages/contracts/src/device.ts | 27 +++++ packages/contracts/src/rpc.ts | 11 +++ 11 files changed, 316 insertions(+), 30 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index f127f94fdba35..a1b05c32a0027 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -193,6 +193,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope, [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope, [WS_METHODS.deviceEasAccount]: AuthSettingsWriteScope, + [WS_METHODS.deviceSimlockIdentity]: AuthSettingsWriteScope, [WS_METHODS.deviceList]: AuthOrchestrationReadScope, [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 869b130105bfe..84e14f1bb14ef 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -20,6 +20,9 @@ import { type DeviceEasAccount, DeviceEasAccountError, type DeviceEasAccountInput, + type DeviceSimlockIdentity, + DeviceSimlockIdentityError, + type DeviceSimlockIdentityInput, type DeviceHostConfig, type EasDeviceHostConfig, type SimlockDeviceHostConfig, @@ -69,6 +72,7 @@ import * as DeviceHost from "./DeviceHost.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; import * as Exit from "effect/Exit"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; @@ -142,6 +146,10 @@ export class DeviceService extends Context.Service< readonly easAccount: ( input: DeviceEasAccountInput, ) => Effect.Effect; + /** Who a Simlock agent token is; without a gateway or token, this machine's agent-device's. */ + readonly simlockIdentity: ( + input: DeviceSimlockIdentityInput, + ) => Effect.Effect; /** * The agent-device flags that reach the device: its config and session, * and the id agent-device selects it by, which can differ from its id here. @@ -1012,6 +1020,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); }), + simlockIdentity: Effect.fn("DeviceService.simlockIdentity")(function () { + return Effect.fail( + new DeviceSimlockIdentityError({ + reason: "Simlock hosts are unavailable in this device service.", + }), + ); + }), agentTarget: Effect.fn("DeviceService.agentTarget")(function* (input) { const host = yield* resolveHost(input.hostId); const ready = yield* agentReadinessIfSupported(input.hostId); @@ -1486,9 +1501,60 @@ export const make = Effect.gen(function* () { ), ); + const simlockIdentity: DeviceService["Service"]["simlockIdentity"] = Effect.fn( + "DeviceService.simlockIdentity", + )( + function* (input) { + const environment = yield* HostProcess.Environment; + const gatewayUrl = ( + input.gatewayUrl || SimlockDeviceHost.machineSimlockToken(environment)?.gatewayUrl + )?.replace(/\/$/, ""); + if (!gatewayUrl) + return yield* new DeviceSimlockIdentityError({ + reason: "This machine's agent-device does not point at a Simlock gateway.", + }); + const saved = + input.hostId === undefined + ? "" + : ((yield* settings.getSettings).simlockDeviceHosts.find( + (host) => host.id === input.hostId, + )?.agentToken ?? ""); + const token = input.agentToken + ? { agentToken: input.agentToken, source: "entered" as const } + : SimlockDeviceHost.resolveSimlockToken(gatewayUrl, saved, environment); + if (!token) + return yield* new DeviceSimlockIdentityError({ + reason: "No agent token for this gateway on this machine.", + }); + const identity = yield* SimlockDeviceHost.simlockIdentity(gatewayUrl, token.agentToken).pipe( + Effect.mapError( + (cause) => + new DeviceSimlockIdentityError({ + reason: SimlockDeviceHost.describeGatewayFailure( + cause, + gatewayUrl, + token.source === "env" ? "this machine" : "this environment", + ), + }), + ), + ); + if (identity.role !== "agent") + return yield* new DeviceSimlockIdentityError({ + reason: `That is a ${identity.role} token. Create one with --role agent.`, + }); + return { gatewayUrl, signedInAs: identity.name, tokenSource: token.source }; + }, + Effect.catchTags({ + ServerSettingsError: () => + Effect.fail(new DeviceSimlockIdentityError({ reason: "Could not read the saved token." })), + }), + Effect.provide(simlockContext), + ); + return { ...service, easAccount, + simlockIdentity, }; }); diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index 11b041d8705f2..67d03459c35f9 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { SimlockDeviceHostConfig } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; @@ -274,6 +275,37 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("uses this machine's agent-device token only for the gateway it targets", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const availability = (baseUrl: string) => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ agentToken: "" }), scope).pipe( + Effect.provideService(HostProcess.Environment, { + AGENT_DEVICE_DAEMON_BASE_URL: baseUrl, + AGENT_DEVICE_DAEMON_AUTH_TOKEN: "agent-token", + }), + ); + const result = yield* host.platformAvailability("ios"); + yield* Scope.close(scope, Exit.void); + return result; + }); + + expect(yield* availability(`${GATEWAY}/v1/agent-device`)).toEqual({ + platform: "ios", + available: true, + }); + const requests = pool.requests.length; + expect(yield* availability("https://elsewhere.example:4700/v1/agent-device")).toEqual({ + platform: "ios", + available: false, + reason: "Add an agent token for Device pool in Settings.", + }); + expect(pool.requests).toHaveLength(requests); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("closes the session of a failed open so no lease is left behind", () => Effect.gen(function* () { const pool = yield* fakePool({ failOpen: true }); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index d9d786b7a00ed..1dc5dd633c439 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -84,6 +84,75 @@ const parseCatalogId = (id: string) => { const platformVersion = (platform: DevicePlatform, runtime: string | undefined) => `${platform === "ios" ? "iOS" : "Android"}${runtime ? ` ${runtime}` : ""}`; +const AGENT_DEVICE_PATH = "/v1/agent-device"; +const sameOrigin = (left: string, right: string) => { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +}; + +/** + * The gateway and token this machine's agent-device uses, from agent-device's own remote-daemon + * variables, which Simlock tells its clients to set. Null unless they point at a Simlock gateway. + */ +export const machineSimlockToken = (environment: NodeJS.ProcessEnv) => { + const base = environment.AGENT_DEVICE_DAEMON_BASE_URL?.trim().replace(/\/+$/, ""); + const agentToken = environment.AGENT_DEVICE_DAEMON_AUTH_TOKEN?.trim(); + if (!base?.endsWith(AGENT_DEVICE_PATH) || !agentToken) return null; + const gatewayUrl = base.slice(0, -AGENT_DEVICE_PATH.length); + return URL.canParse(gatewayUrl) ? { gatewayUrl, agentToken } : null; +}; + +/** The saved token, else this machine's agent-device token, but only for the gateway it targets. */ +export const resolveSimlockToken = ( + gatewayUrl: string, + saved: string, + environment: NodeJS.ProcessEnv, +) => { + if (saved) return { agentToken: saved, source: "settings" as const }; + const machine = machineSimlockToken(environment); + return machine && sameOrigin(machine.gatewayUrl, gatewayUrl) + ? { agentToken: machine.agentToken, source: "env" as const } + : null; +}; + +/** What went wrong talking to a gateway, without the gateway's own text. */ +export const describeGatewayFailure = (cause: unknown, gatewayUrl: string, tokenOwner: string) => { + const status = HttpClientError.isHttpClientError(cause) ? cause.response?.status : undefined; + return status === 401 || status === 403 + ? `The Simlock gateway refused the agent token for ${tokenOwner}.` + : status !== undefined + ? `The Simlock gateway at ${gatewayUrl} answered HTTP ${status}.` + : `Could not reach the Simlock gateway at ${gatewayUrl}.`; +}; + +const decodeIdentity = Schema.decodeUnknownEffect( + Schema.Struct({ name: Schema.String, role: Schema.String }), +); + +/** Who the gateway says a token is, via GET /v1/identity. */ +export const simlockIdentity = Effect.fn("SimlockDeviceHost.identity")(function* ( + gatewayUrl: string, + agentToken: string, +) { + const http = yield* HttpClient.HttpClient; + return yield* http + .execute( + HttpClientRequest.get(`${gatewayUrl.replace(/\/$/, "")}/v1/identity`).pipe( + HttpClientRequest.bearerToken(agentToken), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeIdentity), + Effect.scoped, + Effect.timeout(REQUEST_TIMEOUT), + ); +}); + /** T3's own agent-device CLI, installed on first use. */ export type AgentDeviceCli = Effect.Effect< { readonly nodePath: string; readonly entryPath: string }, @@ -103,6 +172,8 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const environment = yield* HostProcess.Environment; const hostId = config.id; const gateway = config.gatewayUrl.replace(/\/$/, ""); + const agentToken = + resolveSimlockToken(config.gatewayUrl, config.agentToken, environment)?.agentToken ?? ""; const agentDeviceUrl = `${gateway}/v1/agent-device`; const hostDir = path.join(server.stateDir, "device", "simlock", hostId); const leases = new Map(); @@ -142,7 +213,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( HttpClientRequest.bodyJsonUnsafe(body ?? {}), ) : HttpClientRequest.delete(`${gateway}${route}`) - ).pipe(HttpClientRequest.bearerToken(config.agentToken)), + ).pipe(HttpClientRequest.bearerToken(agentToken)), ) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -203,24 +274,17 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const fetchCatalog = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices; - if (!config.agentToken) { + if (!agentToken) { return yield* unavailable(`Add an agent token for ${config.label} in Settings.`); } const decoded = yield* request("GET", "/v1/catalog").pipe( Effect.flatMap(decodeCatalog), - Effect.mapError((cause) => { - const status = HttpClientError.isHttpClientError(cause) - ? cause.response?.status - : undefined; - return unavailable( - status === 401 || status === 403 - ? `The Simlock gateway refused the agent token for ${config.label}. Check it in Settings.` - : status !== undefined - ? `The Simlock gateway at ${gateway} answered HTTP ${status}.` - : `Could not reach the Simlock gateway at ${gateway}.`, + Effect.mapError((cause) => + unavailable( + `${describeGatewayFailure(cause, gateway, config.label)} Check it in Settings.`, cause, - ); - }), + ), + ), ); const devices = decoded.platforms.flatMap((entry) => { if (entry.platform !== "ios" && entry.platform !== "android") return []; @@ -354,7 +418,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const configPath = path.join(dir, "agent-device.json"); yield* writeAgentDeviceConfig(configPath, { baseUrl: agentDeviceUrl, - token: config.agentToken, + token: agentToken, stateDir: dir, }).pipe( Effect.provide(services), @@ -480,7 +544,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( onSome: (lease) => Effect.succeed({ baseUrl: agentDeviceUrl, - token: config.agentToken, + token: agentToken, agentDeviceId: null, session: lease.session, stateDir: lease.dir, @@ -511,7 +575,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( if (!lease) return null; return { url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`, - headers: { authorization: `Bearer ${config.agentToken}` }, + headers: { authorization: `Bearer ${agentToken}` }, protocols: [], }; }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 504e533b37e05..fcbbde88f4b1d 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -203,6 +203,7 @@ const RPC_AGGREGATES = { [WS_METHODS.deviceConfigure]: "device", [WS_METHODS.deviceTestHost]: "device", [WS_METHODS.deviceEasAccount]: "device", + [WS_METHODS.deviceSimlockIdentity]: "device", [WS_METHODS.deviceList]: "device", [WS_METHODS.deviceOpen]: "device", [WS_METHODS.deviceClose]: "device", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e1d6303b23506..34eb11815b13f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3020,6 +3020,7 @@ export const layerWsRpc = ( [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), [WS_METHODS.deviceTestHost]: (input) => deviceService.testHost(input), [WS_METHODS.deviceEasAccount]: (input) => deviceService.easAccount(input), + [WS_METHODS.deviceSimlockIdentity]: (input) => deviceService.simlockIdentity(input), [WS_METHODS.deviceList]: (input) => input.inspectOnly && !input.updateTool ? deviceService.inspect diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 69e23c3cdf964..09ccc32fd61b9 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -507,14 +507,8 @@ export function DeviceSourcesSettings(props: { label: member.label, added: true, platforms: hostPlatforms(member.state, host.id), - status: - held.agentToken === "" - ? ({ - tone: "warning", - label: "No agent token", - ...(disabled ? {} : { action: { label: "Add token", run: edit } }), - } as const) - : hostStatus(member.state, host.id), + // A blank token can still resolve to the machine's own, so the host's answer decides. + status: hostStatus(member.state, host.id), }; }), actions: rowActions(edit, () => removeSimlock(host)), diff --git a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx index b9e611f8a9f0e..3498492d59cec 100644 --- a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx +++ b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx @@ -1,7 +1,14 @@ +import { useAtomValue } from "@effect/atom-react"; import { useId, useState, type ReactNode } from "react"; import type { EnvironmentId, SimlockDeviceHostConfig } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; import { XIcon } from "lucide-react"; +import { deviceSimlockIdentityCheck } from "../../state/device"; +import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; + import { Button } from "../ui/button"; import { Dialog, @@ -17,10 +24,23 @@ import { Spinner } from "../ui/spinner"; import { SecretInput } from "./SecretInput"; import { planSimlockDeviceHostSave, + simlockDeviceHostProblem, type SimlockHostEnvironment, type SimlockTokenEntry, } from "./simlockDeviceHostsSettings.logic"; +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** The gateway each environment's own agent-device points at, if any. */ +const machineGatewaysAtom = environmentAtomList("web-simlock-machine-gateways", (environmentId) => + deviceSimlockIdentityCheck({ environmentId, input: {} }), +); + +const NO_CHECK = Atom.make(AsyncResult.initial()).pipe(Atom.withLabel("simlock-identity:none")); + /** Shared fields once, then one agent token per environment: Simlock counts leases per token. */ export function SimlockDeviceHostEditor(props: { host: SimlockDeviceHostConfig; @@ -34,6 +54,19 @@ export function SimlockDeviceHostEditor(props: { onClose: () => void; }) { const [draft, setDraft] = useState(props.host); + const [urlEdited, setUrlEdited] = useState(false); + const machineGateways = useAtomValue( + machineGatewaysAtom( + environmentIdsKey( + props.isNew ? props.environments.map((environment) => environment.environmentId) : [], + ), + ), + ); + // A new host starts at the gateway an environment's agent-device already uses. + const detectedUrl = machineGateways + .map((result) => Option.getOrUndefined(AsyncResult.value(result))?.gatewayUrl) + .find(Boolean); + const gatewayUrl = urlEdited || draft.gatewayUrl ? draft.gatewayUrl : (detectedUrl ?? ""); const savedToken = (environment: SimlockHostEnvironment) => environment.hosts.some((host) => host.id === props.host.id && host.agentToken !== ""); const [tokens, setTokens] = useState>( @@ -48,8 +81,9 @@ export function SimlockDeviceHostEditor(props: { const next: SimlockDeviceHostConfig = { ...draft, label: draft.label.trim(), - gatewayUrl: draft.gatewayUrl.trim(), + gatewayUrl: gatewayUrl.trim(), }; + const checkUrl = simlockDeviceHostProblem(next) === null ? next.gatewayUrl : null; const plan = planSimlockDeviceHostSave(props.environments, next, tokens); const problem = "problem" in plan ? plan.problem : null; const several = props.environments.length > 1; @@ -119,10 +153,11 @@ export function SimlockDeviceHostEditor(props: { required type="url" placeholder="https://gateway.example.ts.net:4700" - value={draft.gatewayUrl} + value={gatewayUrl} disabled={props.busy} onChange={(event) => { const gatewayUrl = event.target.value; + setUrlEdited(true); setDraft((current) => ({ ...current, gatewayUrl })); }} /> @@ -131,6 +166,9 @@ export function SimlockDeviceHostEditor(props: { {props.environments.map((environment) => ( Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} - with simlock --gateway token create --role agent - {several ? ", or leave an environment blank to add its token later" : ""}. + with simlock --gateway token create --role agent, or leave it blank to + add it later. A blank token uses the machine's{" "} + AGENT_DEVICE_DAEMON_AUTH_TOKEN when its{" "} + AGENT_DEVICE_DAEMON_BASE_URL is this gateway.

@@ -154,7 +194,7 @@ export function SimlockDeviceHostEditor(props: { A lease ends after the lease minutes even while in use. {props.isNew ? null : " Saving restarts this host, which releases its devices."}

- {problem === null || !draft.gatewayUrl ? null : ( + {problem === null || !gatewayUrl ? null : (

{problem}

@@ -175,6 +215,9 @@ export function SimlockDeviceHostEditor(props: { } function SimlockTokenField(props: { + environmentId: EnvironmentId; + hostId: string | null; + gatewayUrl: string | null; label: ReactNode; token: SimlockTokenEntry; hasSaved: boolean; @@ -182,6 +225,40 @@ function SimlockTokenField(props: { onChange: (token: SimlockTokenEntry) => void; }) { const id = useId(); + const entered = props.token.kind === "new" ? props.token.value.trim() : ""; + // A cleared field asks about the machine's own token, which a blank one falls back to. + const check = useAtomValue( + props.gatewayUrl === null + ? NO_CHECK + : deviceSimlockIdentityCheck({ + environmentId: props.environmentId, + input: { + gatewayUrl: props.gatewayUrl, + ...(entered + ? { agentToken: entered } + : props.token.kind === "saved" && props.hostId !== null + ? { hostId: props.hostId } + : {}), + }, + }), + ); + const identity = AsyncResult.isSuccess(check) ? check.value : null; + const status = + props.gatewayUrl === null ? null : check._tag === "Initial" || check.waiting ? ( + Checking… + ) : identity ? ( + + {identity.tokenSource === "env" + ? `Using this machine's agent-device token (${identity.signedInAs}).` + : `Accepted as ${identity.signedInAs}.`} + + ) : AsyncResult.isFailure(check) ? ( + + {failureMessage(check.cause)} + + ) : null; return (
+ {status ?

{status}

: null}
); } diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index fcd4bb4b8f2ec..da19cc7d5a2b9 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -171,3 +171,14 @@ export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connect staleTimeMs: 0, idleTtlMs: 0, }); + +/** Who a Simlock token is, checked as the host editor's URL and token fields change. */ +export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:device:simlock-identity-check", + tag: WS_METHODS.deviceSimlockIdentity, + staleTimeMs: 0, + idleTtlMs: 0, + }, +); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 2335d6ba8d654..24b72dbe26d7a 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -127,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e4823f30272bd..658f6276e247d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -293,6 +293,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -473,6 +476,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1613,6 +1617,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -2088,6 +2098,7 @@ export const WsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, From 9868287c855ee993ff7bf87c8edb2e2ba22c4c42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:43:38 -0700 Subject: [PATCH 3/5] fix(devices): an empty Simlock catalog is not cached, so a worker reconnect does not hide the pool for a minute Co-Authored-By: Claude Opus 5.5 --- .../src/device/SimlockDeviceHost.test.ts | 23 ++++++++++++++++++- apps/server/src/device/SimlockDeviceHost.ts | 3 ++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index 67d03459c35f9..b21589f8c2acf 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -63,6 +63,7 @@ const fakePool = ( const commands: Array<{ readonly args: ReadonlyArray; readonly cwd?: string }> = []; let leases = 0; let renewStatus = 200; + let catalog: unknown = CATALOG; const http = HttpClient.make((request) => Effect.sync(() => { const url = new URL(request.url); @@ -71,7 +72,7 @@ const fakePool = ( HttpClientResponse.fromWeb(request, Response.json(body, { status })); if (request.headers.authorization !== "Bearer agent-token") return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401); - if (url.pathname === "/v1/catalog") return json(CATALOG); + if (url.pathname === "/v1/catalog") return json(catalog); if (url.pathname.endsWith("/renew")) return json({}, renewStatus); if (request.method === "DELETE") return json({}, 202); return json({}, 404); @@ -136,6 +137,9 @@ const fakePool = ( endLeases: () => { renewStatus = 404; }, + setCatalog: (next: unknown) => { + catalog = next; + }, }; }); @@ -306,6 +310,23 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("lists the pool again as soon as its workers return", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + // Mid-reconnect, the gateway knows the models but no worker that makes them. + pool.setCatalog({ + platforms: [{ ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } }], + }); + expect((yield* ready.devices.list).devices).toEqual([]); + pool.setCatalog(CATALOG); + expect((yield* ready.devices.list).devices.length).toBeGreaterThan(0); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("closes the session of a failed open so no lease is left behind", () => Effect.gen(function* () { const pool = yield* fakePool({ failOpen: true }); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index 1dc5dd633c439..aa6706cccf6a3 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -306,7 +306,8 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( ]; }); }); - catalog = { at: now, devices }; + // An empty catalog is usually workers reconnecting; caching it would hide the pool for the TTL. + catalog = devices.length > 0 ? { at: now, devices } : null; return devices; }); From 7447e39c9bea9efe0bf896eac4f67accdc906c83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 17:33:35 -0700 Subject: [PATCH 4/5] fix(devices): a saved Simlock token only goes to its own gateway, and a platform whose workers are reconnecting is not cached Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.ts | 14 +++--- .../src/device/SimlockDeviceHost.test.ts | 28 ++++++++--- apps/server/src/device/SimlockDeviceHost.ts | 49 ++++++++++++------- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 84e14f1bb14ef..b2ef41f148a14 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1513,18 +1513,20 @@ export const make = Effect.gen(function* () { return yield* new DeviceSimlockIdentityError({ reason: "This machine's agent-device does not point at a Simlock gateway.", }); - const saved = + const savedHost = input.hostId === undefined - ? "" - : ((yield* settings.getSettings).simlockDeviceHosts.find( + ? undefined + : (yield* settings.getSettings).simlockDeviceHosts.find( (host) => host.id === input.hostId, - )?.agentToken ?? ""); + ); const token = input.agentToken ? { agentToken: input.agentToken, source: "entered" as const } - : SimlockDeviceHost.resolveSimlockToken(gatewayUrl, saved, environment); + : SimlockDeviceHost.resolveSimlockToken(gatewayUrl, savedHost, environment); if (!token) return yield* new DeviceSimlockIdentityError({ - reason: "No agent token for this gateway on this machine.", + reason: savedHost?.agentToken + ? "The saved token is for another gateway. Enter one for this gateway." + : "No agent token for this gateway on this machine.", }); const identity = yield* SimlockDeviceHost.simlockIdentity(gatewayUrl, token.agentToken).pipe( Effect.mapError( diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index b21589f8c2acf..2ab29ab5de2c5 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -271,15 +271,14 @@ describe("Simlock device host", () => { expect(yield* host.platformAvailability("ios")).toEqual({ platform: "ios", available: false, - reason: - "The Simlock gateway refused the agent token for Device pool. Check it in Settings.", + reason: "The Simlock gateway refused the agent token for Device pool.", }); expect((yield* host.summary).platforms.every((platform) => !platform.available)).toBe(true); yield* Scope.close(scope, Exit.void); }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("uses this machine's agent-device token only for the gateway it targets", () => + it.effect("sends saved and machine tokens only to the gateway each belongs to", () => Effect.gen(function* () { const pool = yield* fakePool(); const availability = (baseUrl: string) => @@ -296,6 +295,14 @@ describe("Simlock device host", () => { return result; }); + expect( + SimlockDeviceHost.resolveSimlockToken( + "https://elsewhere.example:4700", + config({ agentToken: "saved-token" }), + {}, + ), + ).toBeNull(); + expect(yield* availability(`${GATEWAY}/v1/agent-device`)).toEqual({ platform: "ios", available: true, @@ -310,19 +317,24 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("lists the pool again as soon as its workers return", () => + it.effect("lists a platform again as soon as its workers return", () => Effect.gen(function* () { const pool = yield* fakePool(); const scope = yield* Scope.make(); const host = yield* makeHost(pool, config(), scope); const ready = yield* host.ensureReady(() => Effect.void); - // Mid-reconnect, the gateway knows the models but no worker that makes them. + // Mid-reconnect, the gateway knows the Android models but no worker that makes them. pool.setCatalog({ - platforms: [{ ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } }], + platforms: [ + CATALOG.platforms[0]!, + { ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } }, + ], }); - expect((yield* ready.devices.list).devices).toEqual([]); + const during = (yield* ready.devices.list).devices; + expect(during.some((device) => device.platform === "android")).toBe(false); pool.setCatalog(CATALOG); - expect((yield* ready.devices.list).devices.length).toBeGreaterThan(0); + const after = (yield* ready.devices.list).devices; + expect(after.some((device) => device.platform === "android")).toBe(true); yield* Scope.close(scope, Exit.void); }).pipe(Effect.scoped, Effect.provide(layer)), ); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index aa6706cccf6a3..8c809eaf34651 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -13,6 +13,7 @@ import { type DevicePlatformAvailability, type SimlockDeviceHostConfig, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -105,13 +106,17 @@ export const machineSimlockToken = (environment: NodeJS.ProcessEnv) => { return URL.canParse(gatewayUrl) ? { gatewayUrl, agentToken } : null; }; -/** The saved token, else this machine's agent-device token, but only for the gateway it targets. */ +/** + * The saved token, else this machine's agent-device token; each only for the gateway it belongs to, + * so a URL being edited never receives either. + */ export const resolveSimlockToken = ( gatewayUrl: string, - saved: string, + saved: { readonly gatewayUrl: string; readonly agentToken: string } | undefined, environment: NodeJS.ProcessEnv, ) => { - if (saved) return { agentToken: saved, source: "settings" as const }; + if (saved?.agentToken && sameOrigin(saved.gatewayUrl, gatewayUrl)) + return { agentToken: saved.agentToken, source: "settings" as const }; const machine = machineSimlockToken(environment); return machine && sameOrigin(machine.gatewayUrl, gatewayUrl) ? { agentToken: machine.agentToken, source: "env" as const } @@ -120,12 +125,18 @@ export const resolveSimlockToken = ( /** What went wrong talking to a gateway, without the gateway's own text. */ export const describeGatewayFailure = (cause: unknown, gatewayUrl: string, tokenOwner: string) => { - const status = HttpClientError.isHttpClientError(cause) ? cause.response?.status : undefined; - return status === 401 || status === 403 - ? `The Simlock gateway refused the agent token for ${tokenOwner}.` - : status !== undefined - ? `The Simlock gateway at ${gatewayUrl} answered HTTP ${status}.` - : `Could not reach the Simlock gateway at ${gatewayUrl}.`; + if (Cause.isTimeoutError(cause)) return `Could not reach the Simlock gateway at ${gatewayUrl}.`; + if (!HttpClientError.isHttpClientError(cause)) + return `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`; + if (cause.reason._tag === "StatusCodeError") { + const status = cause.reason.response.status; + return status === 401 || status === 403 + ? `The Simlock gateway refused the agent token for ${tokenOwner}.` + : `The Simlock gateway at ${gatewayUrl} answered HTTP ${status}.`; + } + return cause.reason._tag === "TransportError" + ? `Could not reach the Simlock gateway at ${gatewayUrl}.` + : `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`; }; const decodeIdentity = Schema.decodeUnknownEffect( @@ -172,8 +183,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const environment = yield* HostProcess.Environment; const hostId = config.id; const gateway = config.gatewayUrl.replace(/\/$/, ""); - const agentToken = - resolveSimlockToken(config.gatewayUrl, config.agentToken, environment)?.agentToken ?? ""; + const agentToken = resolveSimlockToken(config.gatewayUrl, config, environment)?.agentToken ?? ""; const agentDeviceUrl = `${gateway}/v1/agent-device`; const hostDir = path.join(server.stateDir, "device", "simlock", hostId); const leases = new Map(); @@ -280,14 +290,11 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const decoded = yield* request("GET", "/v1/catalog").pipe( Effect.flatMap(decodeCatalog), Effect.mapError((cause) => - unavailable( - `${describeGatewayFailure(cause, gateway, config.label)} Check it in Settings.`, - cause, - ), + unavailable(describeGatewayFailure(cause, gateway, config.label), cause), ), ); - const devices = decoded.platforms.flatMap((entry) => { - if (entry.platform !== "ios" && entry.platform !== "android") return []; + const byPlatform = decoded.platforms.map((entry) => { + if (entry.platform !== "ios" && entry.platform !== "android") return null; const platform: DevicePlatform = entry.platform; return Object.entries(entry.modelRuntimes).flatMap(([model, runtimes]) => { const kind = entry.modelClasses?.[model]; @@ -306,8 +313,12 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( ]; }); }); - // An empty catalog is usually workers reconnecting; caching it would hide the pool for the TTL. - catalog = devices.length > 0 ? { at: now, devices } : null; + const devices = byPlatform.flatMap((entry) => entry ?? []); + // A platform with nothing to offer is usually its workers reconnecting; caching that would + // hide it for the TTL, so only a catalog with devices on every platform is kept. + catalog = byPlatform.every((entry) => entry === null || entry.length > 0) + ? { at: now, devices } + : null; return devices; }); From f145a3f564bbf74a2ece4f941954cb5a309f09ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 17:48:48 -0700 Subject: [PATCH 5/5] fix(devices): a Simlock catalog with no platforms is not cached either Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/SimlockDeviceHost.test.ts | 2 ++ apps/server/src/device/SimlockDeviceHost.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index 2ab29ab5de2c5..1b670a06cd078 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -323,6 +323,8 @@ describe("Simlock device host", () => { const scope = yield* Scope.make(); const host = yield* makeHost(pool, config(), scope); const ready = yield* host.ensureReady(() => Effect.void); + pool.setCatalog({ platforms: [] }); + expect((yield* ready.devices.list).devices).toEqual([]); // Mid-reconnect, the gateway knows the Android models but no worker that makes them. pool.setCatalog({ platforms: [ diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index 8c809eaf34651..75476f0333ce3 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -316,9 +316,10 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const devices = byPlatform.flatMap((entry) => entry ?? []); // A platform with nothing to offer is usually its workers reconnecting; caching that would // hide it for the TTL, so only a catalog with devices on every platform is kept. - catalog = byPlatform.every((entry) => entry === null || entry.length > 0) - ? { at: now, devices } - : null; + catalog = + devices.length > 0 && byPlatform.every((entry) => entry === null || entry.length > 0) + ? { at: now, devices } + : null; return devices; });