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 5f821f5bf0217..b2ef41f148a14 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); @@ -1379,6 +1394,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") { @@ -1478,9 +1501,62 @@ 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 savedHost = + input.hostId === undefined + ? undefined + : (yield* settings.getSettings).simlockDeviceHosts.find( + (host) => host.id === input.hostId, + ); + const token = input.agentToken + ? { agentToken: input.agentToken, source: "entered" as const } + : SimlockDeviceHost.resolveSimlockToken(gatewayUrl, savedHost, environment); + if (!token) + return yield* new DeviceSimlockIdentityError({ + 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( + (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 f67f7bc8d76c6..1b670a06cd078 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"; @@ -62,14 +63,16 @@ 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); 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 (url.pathname === "/v1/catalog") return json(CATALOG); + 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); return json({}, 404); @@ -134,6 +137,9 @@ const fakePool = ( endLeases: () => { renewStatus = 404; }, + setCatalog: (next: unknown) => { + catalog = next; + }, }; }); @@ -257,6 +263,84 @@ 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.", + }); + 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("sends saved and machine tokens only to the gateway each belongs to", () => + 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( + SimlockDeviceHost.resolveSimlockToken( + "https://elsewhere.example:4700", + config({ agentToken: "saved-token" }), + {}, + ), + ).toBeNull(); + + 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("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); + 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: [ + CATALOG.platforms[0]!, + { ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } }, + ], + }); + const during = (yield* ready.devices.list).devices; + expect(during.some((device) => device.platform === "android")).toBe(false); + pool.setCatalog(CATALOG); + 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)), + ); + 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..75476f0333ce3 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -10,8 +10,10 @@ import { DeviceHostUnavailableError, DeviceOperationError, type DevicePlatform, + 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"; @@ -22,7 +24,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"; @@ -83,6 +85,85 @@ 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; each only for the gateway it belongs to, + * so a URL being edited never receives either. + */ +export const resolveSimlockToken = ( + gatewayUrl: string, + saved: { readonly gatewayUrl: string; readonly agentToken: string } | undefined, + environment: NodeJS.ProcessEnv, +) => { + 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 } + : null; +}; + +/** What went wrong talking to a gateway, without the gateway's own text. */ +export const describeGatewayFailure = (cause: unknown, gatewayUrl: string, tokenOwner: string) => { + 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( + 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 }, @@ -102,6 +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, environment)?.agentToken ?? ""; const agentDeviceUrl = `${gateway}/v1/agent-device`; const hostDir = path.join(server.stateDir, "device", "simlock", hostId); const leases = new Map(); @@ -114,6 +196,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; @@ -139,7 +223,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), @@ -197,23 +281,20 @@ 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) { + 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) => - unavailable( - `Could not reach the Simlock gateway at ${gateway}, or it refused the agent token.`, - 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]; @@ -232,10 +313,41 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( ]; }); }); - catalog = { at: now, devices }; + 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 = + devices.length > 0 && byPlatform.every((entry) => entry === null || entry.length > 0) + ? { at: now, devices } + : null; 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); @@ -319,7 +431,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), @@ -445,7 +557,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, @@ -476,7 +588,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: [], }; }); @@ -515,19 +627,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/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 7564d86e67a25..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. + 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.

@@ -153,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}

@@ -174,6 +215,9 @@ export function SimlockDeviceHostEditor(props: { } function SimlockTokenField(props: { + environmentId: EnvironmentId; + hostId: string | null; + gatewayUrl: string | null; label: ReactNode; token: SimlockTokenEntry; hasSaved: boolean; @@ -181,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/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), 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,