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,