Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
type DeviceEasAccount,
DeviceEasAccountError,
type DeviceEasAccountInput,
type DeviceSimlockIdentity,
DeviceSimlockIdentityError,
type DeviceSimlockIdentityInput,
type DeviceHostConfig,
type EasDeviceHostConfig,
type SimlockDeviceHostConfig,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -142,6 +146,10 @@ export class DeviceService extends Context.Service<
readonly easAccount: (
input: DeviceEasAccountInput,
) => Effect.Effect<DeviceEasAccount, DeviceEasAccountError>;
/** Who a Simlock agent token is; without a gateway or token, this machine's agent-device's. */
readonly simlockIdentity: (
input: DeviceSimlockIdentityInput,
) => Effect.Effect<DeviceSimlockIdentity, DeviceSimlockIdentityError>;
/**
* 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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
};
});

Expand Down
88 changes: 86 additions & 2 deletions apps/server/src/device/SimlockDeviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -62,14 +63,16 @@ const fakePool = (
const commands: Array<{ readonly args: ReadonlyArray<string>; 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);
Expand Down Expand Up @@ -134,6 +137,9 @@ const fakePool = (
endLeases: () => {
renewStatus = 404;
},
setCatalog: (next: unknown) => {
catalog = next;
},
};
});

Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading