Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
86 changes: 84 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,82 @@ 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);
// 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