From 86fe1be47f38b3605b3cc1122e098a82e4419ee4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:33:21 +0200 Subject: [PATCH 01/10] test: an agent opens and closes a device through simlock (#472) --- src/agent-device/edge.test.ts | 1155 ++++++++++++++++++++++ src/agent-device/front/front-map.test.ts | 50 + src/agent-device/front/front-map.ts | 30 + src/agent-device/host/host.ts | 12 + src/agent-device/host/upstream.test.ts | 154 +++ src/agent-device/host/upstream.ts | 28 + src/agent-device/index.ts | 1 + src/agent-device/shared/rpc.test.ts | 21 + src/agent-device/shared/rpc.ts | 3 + src/agent-device/testing.ts | 279 ++++++ src/daemon/main.ts | 2 + 11 files changed, 1735 insertions(+) create mode 100644 src/agent-device/edge.test.ts create mode 100644 src/agent-device/front/front-map.test.ts create mode 100644 src/agent-device/front/front-map.ts create mode 100644 src/agent-device/host/host.ts create mode 100644 src/agent-device/host/upstream.test.ts create mode 100644 src/agent-device/host/upstream.ts create mode 100644 src/agent-device/shared/rpc.test.ts create mode 100644 src/agent-device/shared/rpc.ts diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts new file mode 100644 index 000000000..74590efda --- /dev/null +++ b/src/agent-device/edge.test.ts @@ -0,0 +1,1155 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { FakeDriver } from "../core/testing.js"; +import { startDaemon, type StartDaemonOptions } from "../daemon/main.js"; +import type { DaemonServer } from "../daemon/server.js"; +import { + CryptoTokenSecrets, + FakeClock, + JsonLinesLogger, + MemoryFilesystem, + MemoryLogSink, +} from "../ports/index.js"; +import { FakeAgentDevice } from "./testing.js"; + +const runningDaemons: DaemonServer[] = []; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(runningDaemons.splice(0).map((daemon) => daemon.stop("test"))); + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +const secrets = { + one: "slk_test_agent_one", + operator: "slk_test_operator", + two: "slk_test_agent_two", + worker: "slk_test_worker_join", +} as const; +const principals = { one: "tok_one", two: "tok_two" } as const; + +const notAvailable = "agent-device is not available on this simlock: "; +const agentTokenOnly = "refused: agent-device through simlock needs an agent token"; +const queueTimeoutMessage = "timed out waiting for a device; nothing was leased"; +const defaultTtlMs = 600_000; +const maxTtlMs = 1_800_000; +const scope = { clientId: "client-1", leaseProvider: "proxy", runId: "run-1" } as const; + +const adminSession = { + manageEventSubscription: () => undefined, + principal: "admin", + role: "admin", +} as const; + +function sessionFor(principal: string) { + return { manageEventSubscription: () => undefined, principal, role: "agent" } as const; +} + +interface Envelope { + readonly id?: unknown; + readonly jsonrpc?: string; + readonly result?: Record; + readonly error?: { + readonly code: number; + readonly message: string; + readonly data?: { readonly code: string; readonly message: string }; + }; +} + +interface SetUpOptions { + readonly drivers?: (clock: FakeClock) => FakeDriver[]; + readonly enabled?: boolean; + readonly gatewayMode?: boolean; + readonly maxRunning?: number; + readonly slowStart?: boolean; + readonly upstream?: FakeAgentDevice; +} + +interface Rig { + readonly clock: FakeClock; + readonly daemon: DaemonServer; + readonly driver: FakeDriver; + readonly fake: FakeAgentDevice; + readonly filesystem: MemoryFilesystem; + readonly origin: string; + readonly statePath: string; + readonly started: Promise; +} + +function iosDriver(clock: FakeClock): FakeDriver { + return new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + defaultModels: { desktop: ["Mac"], phone: ["iPhone 16"], tv: ["Apple TV 4K"] }, + knownModels: ["iPhone 16", "iPhone 17 Pro", "Apple TV 4K", "Mac"], + modelClasses: { + "Apple TV 4K": "tv", + "iPhone 16": "phone", + "iPhone 17 Pro": "phone", + Mac: "desktop", + }, + platform: "ios", + }); +} + +function androidDriver(clock: FakeClock): FakeDriver { + return new FakeDriver({ + availableOsVersions: ["35"], + clock, + defaultModels: { phone: ["Pixel 8"] }, + knownModels: ["Pixel 8"], + modelClasses: { "Pixel 8": "phone" }, + platform: "android", + }); +} + +async function freeLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (typeof address !== "object" || address === null) throw new Error("no port bound"); + return address.port; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function setUp(options: SetUpOptions = {}): Promise { + const directory = await mkdtemp(join(tmpdir(), "simlock-agent-device-edge-")); + temporaryDirectories.push(directory); + const clock = new FakeClock(1_000); + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp(directory); + const hasher = new CryptoTokenSecrets(); + const record = (id: string, secret: string, role: string) => ({ + createdAt: 0, + hash: hasher.hash(secret), + id, + role, + }); + await filesystem.writeFileAtomic( + join(directory, "tokens.json"), + JSON.stringify([ + record(principals.one, secrets.one, "agent"), + record(principals.two, secrets.two, "agent"), + record("tok_operator", secrets.operator, "operator"), + record("tok_worker", secrets.worker, "worker"), + ]), + ); + const port = await freeLoopbackPort(); + const fake = options.upstream ?? new FakeAgentDevice(); + const drivers = options.drivers?.(clock) ?? [iosDriver(clock)]; + const statePath = join(directory, "state.json"); + const started = startDaemon({ + agentDeviceUpstream: fake, + clock, + configOverrides: { + agentDevice: { enabled: options.enabled ?? true }, + capacity: { config: { maxRunning: options.maxRunning ?? 4 }, strategy: "fixed" }, + http: { enabled: true, host: "127.0.0.1", port }, + lease: { defaultTtlMs, maxTtlMs }, + ...(options.gatewayMode === true ? { mode: "gateway" as const } : {}), + }, + dataDirectory: directory, + drivers, + filesystem, + logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), + statePath, + version: "1.2.3", + } as StartDaemonOptions).then((daemon) => { + runningDaemons.push(daemon); + return daemon; + }); + const origin = `http://127.0.0.1:${port}`; + if (options.slowStart === true) { + await waitListening(origin); + return { + clock, + daemon: undefined as never, + driver: drivers[0] as FakeDriver, + fake, + filesystem, + origin, + started, + statePath, + }; + } + const daemon = await started; + return { + clock, + daemon, + driver: drivers[0] as FakeDriver, + fake, + filesystem, + origin, + started, + statePath, + }; +} + +async function waitListening(origin: string): Promise { + for (let attempt = 0; attempt < 400; attempt += 1) { + try { + await fetch(`${origin}/v1/healthz`); + return; + } catch { + await sleep(5); + } + } + throw new Error("the daemon never listened"); +} + +async function advance(rig: Rig, ms: number, step = 1_000): Promise { + for (let elapsed = 0; elapsed < ms; elapsed += step) { + rig.clock.advance(Math.min(step, ms - elapsed)); + await sleep(3); + } +} + +interface SendOptions { + readonly body?: string; + readonly headers?: Record; + readonly method?: string; + readonly signal?: AbortSignal; + readonly token?: string | null; +} + +function send(rig: Rig, path: string, options: SendOptions = {}): Promise { + const token = options.token === undefined ? secrets.one : options.token; + return fetch(`${rig.origin}/v1/agent-device${path}`, { + ...(options.body === undefined ? {} : { body: options.body }), + headers: { + ...(token === null ? {} : { authorization: `Bearer ${token}` }), + ...(options.body === undefined ? {} : { "content-type": "application/json" }), + ...options.headers, + }, + method: options.method ?? (options.body === undefined ? "GET" : "POST"), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); +} + +function rpcBody(method: string, params: Record, id: unknown = 1): string { + return JSON.stringify({ id, jsonrpc: "2.0", method, params }); +} + +function rpc( + rig: Rig, + method: string, + params: Record, + options: Omit & { readonly id?: unknown } = {}, +): Promise { + return send(rig, "/rpc", { ...options, body: rpcBody(method, params, options.id ?? 1) }); +} + +async function bodyOf(response: Response): Promise> { + const text = await response.text(); + expect( + text.trimStart().startsWith("{"), + `expected a JSON object body, got ${response.status} ${text.slice(0, 80)}`, + ).toBe(true); + return JSON.parse(text) as Record; +} + +async function envelopeOf(response: Response): Promise { + return (await bodyOf(response)) as Envelope; +} + +const hex = (n: number): string => n.toString(16).padStart(32, "0"); + +function allocateParams(n: number, extra: Record = {}): Record { + return { ...scope, leaseId: hex(n), platform: "ios", ttlMs: 120_000, ...extra }; +} + +function allocate( + rig: Rig, + n: number, + extra: Record = {}, + options: Omit = {}, +): Promise { + return rpc(rig, "agent_device.lease.allocate", allocateParams(n, extra), options); +} + +async function granted( + rig: Rig, + n: number, + extra: Record = {}, + options: Omit = {}, +): Promise { + const answer = await envelopeOf(await allocate(rig, n, extra, options)); + expect(answer.result).toMatchObject({ ok: true }); +} + +function release(rig: Rig, n: number, options: Omit = {}): Promise { + return rpc(rig, "agent_device.lease.release", { ...scope, leaseId: hex(n) }, options); +} + +function command( + rig: Rig, + name: string, + n: number | undefined, + options: Omit = {}, + extra: Record = {}, +): Promise { + return rpc( + rig, + "agent_device.command", + { + command: name, + ...(n === undefined ? {} : { meta: { leaseId: hex(n) } }), + ...extra, + }, + options, + ); +} + +interface LeaseView { + readonly id: string; + readonly deviceId: string; + readonly requesterId: string; + readonly ownerId: string; + readonly ttlMs: number; + readonly ttlDeadline: number; + readonly lastRenewedAt: number; +} + +async function leases(rig: Rig): Promise { + return (await rig.daemon.dispatch( + "list.get", + { kind: "leases" }, + adminSession, + )) as unknown as readonly LeaseView[]; +} + +async function waiting( + rig: Rig, +): Promise { + return (await rig.daemon.dispatch("list.get", { kind: "requests" }, adminSession)) as never; +} + +async function devices(rig: Rig): Promise< + readonly { + readonly id: string; + readonly state: string; + readonly address?: string; + readonly driverDeviceId?: string; + }[] +> { + return (await rig.daemon.dispatch("list.get", { kind: "devices" }, adminSession)) as never; +} + +async function requestedSpecs(rig: Rig, requester: string): Promise { + const events = await rig.daemon.dispatch("events.replay", {}, adminSession); + return events + .filter((entry) => entry.event === "lease.requested") + .map((entry) => entry.payload as { requester: string; requestSpec: unknown }) + .filter((payload) => payload.requester === requester) + .map((payload) => payload.requestSpec); +} + +function allocatesReceived(rig: Rig, n?: number) { + return rig.fake + .rpcRequests("agent_device.lease.allocate") + .filter((request) => n === undefined || request.params?.["leaseId"] === hex(n)); +} + +async function simlockMessage(rig: Rig, input: Record): Promise { + try { + await rig.daemon.dispatch("lease.request", input, sessionFor(principals.one)); + } catch (error) { + return (error as Error).message; + } + throw new Error("simlock accepted a request the test expected it to refuse"); +} + +function track(promise: Promise): { + readonly done: () => boolean; + readonly promise: Promise; +} { + let settled = false; + const tracked = promise.finally(() => { + settled = true; + }); + tracked.catch(() => undefined); + return { done: () => settled, promise: tracked }; +} + +function refusal(message: string, code = "INVALID_ARGS") { + return { code: -32000, data: { code, message }, message }; +} + +describe("the agent-device front, through the whole worker daemon", () => { + it("with agentDevice.enabled false, and on a gateway-mode daemon, /v1/agent-device/health answers 404, as today", async () => { + const enabled = await setUp(); + expect((await send(enabled, "/health")).status).toBe(200); + + const off = await setUp({ enabled: false }); + expect((await send(off, "/health")).status).toBe(404); + + const gateway = await setUp({ gatewayMode: true }); + expect((await send(gateway, "/health")).status).toBe(404); + }); + + it("/v1/agent-device/rpc with no simlock token answers 401, with a worker join token 403, and with an operator token 403 `refused: agent-device through simlock needs an agent token`; the agent-device daemon receives nothing", async () => { + const rig = await setUp(); + expect((await command(rig, "snapshot", undefined)).status).toBe(200); + const received = rig.fake.requests.length; + + expect((await command(rig, "snapshot", undefined, { token: null })).status).toBe(401); + expect((await command(rig, "snapshot", undefined, { token: secrets.worker })).status).toBe(403); + const operator = await command(rig, "snapshot", undefined, { token: secrets.operator }); + expect(operator.status).toBe(403); + expect(await operator.text()).toContain(agentTokenOnly); + + expect(rig.fake.requests).toHaveLength(received); + }); + + it("/v1/agent-device/health with a simlock token answers the proxy's health, with the agent-device daemon's health under `upstream`", async () => { + const rig = await setUp(); + + const response = await send(rig, "/health"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + service: "agent-device-proxy", + upstream: { service: "agent-device-daemon" }, + }); + }); + + it("with agentDevice.enabled true and agent-device unavailable, /health, /rpc and an upload each answer `agent-device is not available on this simlock: `, nothing is leased, and POST /v1/lease-requests still grants a device", async () => { + const rig = await setUp(); + const reason = "found 0.20.3, need 0.21.25 or later"; + rig.fake.setUnavailable(reason); + + const health = await send(rig, "/health"); + const rpcAnswer = await allocate(rig, 1); + const upload = await send(rig, "/upload/preflight", { body: "{}" }); + + expect(await health.text()).toContain(`${notAvailable}${reason}`); + expect((await envelopeOf(rpcAnswer)).error?.message).toBe(`${notAvailable}${reason}`); + expect(await upload.text()).toContain(`${notAvailable}${reason}`); + expect(await leases(rig)).toEqual([]); + expect(rig.fake.requests).toEqual([]); + + const granted = await fetch(`${rig.origin}/v1/lease-requests`, { + body: JSON.stringify({ device: "iPhone 16", platform: "ios" }), + headers: { authorization: `Bearer ${secrets.one}`, "content-type": "application/json" }, + method: "POST", + }); + expect(granted.status).toBe(201); + }); + + it("after the agent-device daemon restarts with a new base URL, token and generation, the next request reaches the new daemon", async () => { + const rig = await setUp(); + expect(await bodyOf(await send(rig, "/health"))).toMatchObject({ + upstream: { service: "agent-device-daemon" }, + }); + const before = rig.fake.requests.length; + + rig.fake.restart(); + const response = await send(rig, "/health"); + + expect(await bodyOf(response)).toMatchObject({ upstream: { service: "agent-device-daemon" } }); + expect(rig.fake.requests.length).toBeGreaterThan(before); + }); + + it("commands sent with two simlock tokens reach the agent-device daemon as two tenants, each its token's principal, though the client named another tenant", async () => { + const rig = await setUp(); + const named = { meta: { tenantId: "someone-else" }, tenantId: "someone-else" }; + + await command(rig, "snapshot", undefined, { token: secrets.one }, named); + await command(rig, "snapshot", undefined, { token: secrets.two }, named); + + expect(rig.fake.rpcRequests("agent_device.command").map((request) => request.tenant)).toEqual([ + principals.one, + principals.two, + ]); + }); + + it("allocate `--platform ios` makes a simlock lease whose ID and requester are the CLI's lease ID, owned by the token's principal, and the lease request names no osVersion, mode, imageTag or physical device and offers no download", async () => { + const rig = await setUp(); + + const answer = await allocate(rig, 1); + + expect((await envelopeOf(answer)).result).toMatchObject({ ok: true }); + expect(await leases(rig)).toMatchObject([ + { id: hex(1), ownerId: principals.one, requesterId: hex(1) }, + ]); + expect(await requestedSpecs(rig, hex(1))).toEqual([{ platform: "ios" }]); + + const bare = await setUp({ + drivers: (clock) => [ + new FakeDriver({ + availableOsVersions: [], + clock, + knownModels: ["iPhone 16"], + platform: "ios", + }), + ], + }); + const refused = await allocate(bare, 2); + expect((await envelopeOf(refused)).error?.message).toContain("refused by simlock: "); + expect(bare.driver.calls.map((call) => call.operation)).not.toContain("installComponent"); + }); + + it("the simlock lease's TTL is the CLI's `ttlMs`, 5 s when the CLI sent less, and `lease.defaultTtlMs` when it sent none; the allocate the agent-device daemon receives carries `ttlMs` equal to the ceiling in each case", async () => { + const rig = await setUp(); + + await granted(rig, 1, { ttlMs: 120_000 }); + await granted(rig, 2, { ttlMs: 1_000 }); + await granted(rig, 3, { ttlMs: undefined }); + + const byId = new Map((await leases(rig)).map((lease) => [lease.id, lease.ttlMs])); + expect(byId.get(hex(1))).toBe(120_000); + expect(byId.get(hex(2))).toBe(5_000); + expect(byId.get(hex(3))).toBe(defaultTtlMs); + expect(allocatesReceived(rig).map((request) => request.params?.["ttlMs"])).toEqual([ + rig.fake.leaseMaxTtlMs, + rig.fake.leaseMaxTtlMs, + rig.fake.leaseMaxTtlMs, + ]); + }); + + it('allocate `--target mobile`, `tv` and `desktop` ask simlock for class `phone`, `tv` and `desktop`; allocate `--device "iPhone 17 Pro"`, alone and with `--target tv`, asks for that model and no class', async () => { + const rig = await setUp(); + + await granted(rig, 1, { target: "mobile" }); + await granted(rig, 2, { target: "tv" }); + await granted(rig, 3, { target: "desktop" }); + await granted(rig, 4, { device: "iPhone 17 Pro" }); + await granted(rig, 5, { device: "iPhone 17 Pro", target: "tv" }); + + expect(await requestedSpecs(rig, hex(1))).toEqual([{ class: "phone", platform: "ios" }]); + expect(await requestedSpecs(rig, hex(2))).toEqual([{ class: "tv", platform: "ios" }]); + expect(await requestedSpecs(rig, hex(3))).toEqual([{ class: "desktop", platform: "ios" }]); + expect(await requestedSpecs(rig, hex(4))).toEqual([ + { model: "iPhone 17 Pro", platform: "ios" }, + ]); + expect(await requestedSpecs(rig, hex(5))).toEqual([ + { model: "iPhone 17 Pro", platform: "ios" }, + ]); + }); + + it("an allocate whose `ttlMs` is above `lease.maxTtlMs` answers `refused by simlock:` with simlock's `BAD_REQUEST` message and leaves no simlock lease; one with `ttlMs: null` gets `lease.defaultTtlMs`", async () => { + const rig = await setUp(); + expect((await envelopeOf(await allocate(rig, 1, { ttlMs: null }))).result).toMatchObject({ + ok: true, + }); + expect((await leases(rig)).map((lease) => lease.ttlMs)).toEqual([defaultTtlMs]); + + const over = maxTtlMs + 1; + const expected = await simlockMessage(rig, { leaseId: hex(9), platform: "ios", ttlMs: over }); + const answer = await envelopeOf(await allocate(rig, 2, { ttlMs: over })); + + expect(answer.error?.message).toBe(`refused by simlock: ${expected}`); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + }); + + it('allocate `--device "My test phone"` answers `refused by simlock:` with simlock\'s message, and leaves no simlock lease', async () => { + const rig = await setUp(); + expect( + (await envelopeOf(await allocate(rig, 1, { device: "iPhone 17 Pro" }))).result, + ).toMatchObject({ ok: true }); + const expected = await simlockMessage(rig, { + leaseId: hex(9), + model: "My test phone", + platform: "ios", + }); + + const answer = await envelopeOf(await allocate(rig, 2, { device: "My test phone" })); + + expect(answer.error).toMatchObject(refusal(`refused by simlock: ${expected}`)); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + }); + + describe("each input refused before simlock", () => { + const tooOld = "refused: this agent-device is too old for simlock; upgrade it"; + const ttlRefusal = "refused: ttlMs must be a positive number of milliseconds"; + const plainOnly = "refused: simlock leases only plain devices"; + const lifecycle = "refused: send lease calls as lease RPCs"; + const cases: readonly { + readonly title: string; + readonly message: string; + readonly method?: string; + readonly params: (n: number) => Record; + }[] = [ + { + message: "refused: only iOS and Android", + params: (n) => allocateParams(n, { platform: "macos" }), + title: "--platform macos", + }, + { + message: "refused: simlock picks the device", + params: (n) => allocateParams(n, { udid: "ABC" }), + title: "--udid", + }, + { + message: "refused: simlock picks the device", + params: (n) => allocateParams(n, { serial: "emulator-5554" }), + title: "--serial", + }, + { + message: "refused: name --platform ios or android", + params: (n) => allocateParams(n, { platform: undefined }), + title: "no platform", + }, + { + message: tooOld, + params: (n) => allocateParams(n, { leaseId: undefined }), + title: "no lease ID", + }, + { + message: tooOld, + params: (n) => allocateParams(n, { leaseId: hex(n).toUpperCase().replace(/[0-9]/g, "A") }), + title: "a lease ID that is not 32 lowercase hex", + }, + { + message: ttlRefusal, + params: (n) => allocateParams(n, { ttlMs: "abc" }), + title: 'a ttlMs of "abc"', + }, + { + message: ttlRefusal, + params: (n) => allocateParams(n, { ttlMs: -1 }), + title: "a ttlMs of -1", + }, + { + message: ttlRefusal, + params: (n) => allocateParams(n, { ttlMs: 0 }), + title: "a ttlMs of 0", + }, + { + message: plainOnly, + params: (n) => allocateParams(n, { backend: "cloud" }), + title: "backend", + }, + { + message: plainOnly, + params: (n) => allocateParams(n, { retainOnClose: true }), + title: "retainOnClose: true", + }, + { + message: plainOnly, + params: (n) => allocateParams(n, { providerName: "x" }), + title: "a provider* key", + }, + { + message: plainOnly, + params: (n) => allocateParams(n, { leaseProvider: "aws" }), + title: 'leaseProvider: "aws"', + }, + ...["agent_device.command", "agent-device.command"].flatMap((method) => + ["lease_allocate", "lease_heartbeat", "lease_release"].map((name) => ({ + message: lifecycle, + method, + params: (n: number) => ({ command: name, meta: { leaseId: hex(n) } }), + title: `${method} carrying ${name}`, + })), + ), + ]; + + it.each(cases)( + "$title answers its message, simlock stores no lease request, and the agent-device daemon receives nothing", + async ({ message, method, params }) => { + const rig = await setUp(); + const accepted = await allocate(rig, 1); + expect((await envelopeOf(accepted)).result).toMatchObject({ ok: true }); + const heldBefore = (await leases(rig)).map((lease) => lease.id); + const receivedBefore = rig.fake.requests.length; + + const answer = await rpc(rig, method ?? "agent_device.lease.allocate", params(2)); + + expect((await envelopeOf(answer)).error).toMatchObject(refusal(message)); + expect(await requestedSpecs(rig, hex(2))).toEqual([]); + expect(await waiting(rig)).toEqual([]); + expect((await leases(rig)).map((lease) => lease.id)).toEqual(heldBefore); + expect(rig.fake.requests).toHaveLength(receivedBefore); + }, + ); + }); + + it("allocate and release sent as `agent-device.lease.allocate` and `agent-device.lease.release` act exactly as the `agent_device.lease.*` spelling", async () => { + const rig = await setUp(); + + const allocated = await rpc(rig, "agent-device.lease.allocate", allocateParams(1)); + + expect((await envelopeOf(allocated)).result).toMatchObject({ ok: true }); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + expect(rig.fake.leases.has(hex(1))).toBe(true); + + const released = await rpc(rig, "agent-device.lease.release", { ...scope, leaseId: hex(1) }); + + expect((await envelopeOf(released)).result).toMatchObject({ ok: true }); + expect(await leases(rig)).toEqual([]); + expect(rig.fake.leases.has(hex(1))).toBe(false); + }); + + it("after the grant, the allocate the agent-device daemon receives names the granted device's address by `udid` on iOS and by `serial` on Android (the emulator serial, not the AVD name), and carries no `device`, `deviceKey` or `target`; the CLI gets the daemon's reply with the same lease ID", async () => { + const ios = await setUp(); + const iosAnswer = await allocate(ios, 1, { + device: "iPhone 17 Pro", + deviceKey: "saved", + target: "mobile", + }); + expect((await envelopeOf(iosAnswer)).result).toMatchObject({ ok: true }); + const [iosDevice] = await devices(ios); + const iosParams = allocatesReceived(ios, 1)[0]?.params; + + expect(iosDevice?.address).toBeDefined(); + expect(iosParams?.["udid"]).toBe(iosDevice?.address); + expect(iosParams).not.toHaveProperty("serial"); + for (const dropped of ["device", "deviceKey", "target"]) + expect(iosParams).not.toHaveProperty(dropped); + expect((await envelopeOf(iosAnswer)).result).toMatchObject({ + lease: { leaseId: hex(1) }, + ok: true, + }); + + const android = await setUp({ drivers: (clock) => [androidDriver(clock)] }); + const androidAnswer = await allocate(android, 2, { device: "Pixel 8", platform: "android" }); + expect((await envelopeOf(androidAnswer)).result).toMatchObject({ ok: true }); + const [androidDevice] = await devices(android); + const androidParams = allocatesReceived(android, 2)[0]?.params; + + expect(androidDevice?.address).toBeDefined(); + expect(androidParams?.["serial"]).toBe(androidDevice?.address); + expect(androidParams?.["serial"]).not.toBe(androidDevice?.driverDeviceId); + expect(androidParams).not.toHaveProperty("udid"); + expect((await envelopeOf(androidAnswer)).result).toMatchObject({ + lease: { leaseId: hex(2) }, + ok: true, + }); + }); + + it("with every matching device leased, allocate waits, and is granted and answered once that device is released and reclaimed", async () => { + const rig = await setUp({ maxRunning: 1 }); + expect((await envelopeOf(await allocate(rig, 1))).result).toMatchObject({ ok: true }); + + const second = track(allocate(rig, 2, {}, { token: secrets.two })); + await expect + .poll(async () => (await waiting(rig)).map((request) => request.requesterId)) + .toEqual([hex(2)]); + expect(second.done()).toBe(false); + + await release(rig, 1); + const answer = await envelopeOf(await second.promise); + + expect(answer.result).toMatchObject({ lease: { leaseId: hex(2) }, ok: true }); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(2)]); + }); + + it("a CLI that hangs up while its allocate waits, and one that hangs up before simlock admitted the request, leave simlock's queue, and neither request is granted when a device frees", async () => { + const rig = await setUp({ maxRunning: 1 }); + await granted(rig, 1); + const hangUp = new AbortController(); + const second = track(allocate(rig, 2, {}, { signal: hangUp.signal, token: secrets.two })); + await sleep(50); + expect(second.done()).toBe(false); + await expect.poll(async () => (await waiting(rig)).length).toBe(1); + + hangUp.abort(); + await second.promise.catch(() => undefined); + await expect.poll(async () => (await waiting(rig)).length).toBe(0); + await release(rig, 1); + await sleep(50); + + expect(await leases(rig)).toEqual([]); + expect(allocatesReceived(rig, 2)).toEqual([]); + + const slow = await setUp({ + drivers: (clock) => [ + new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + latencyMs: { listManaged: 30_000 }, + platform: "ios", + }), + ], + slowStart: true, + }); + const early = new AbortController(); + const parked = track( + fetch(`${slow.origin}/v1/agent-device/rpc`, { + body: rpcBody("agent_device.lease.allocate", allocateParams(3)), + headers: { authorization: `Bearer ${secrets.one}`, "content-type": "application/json" }, + method: "POST", + signal: early.signal, + }), + ); + await sleep(100); + expect(parked.done()).toBe(false); + early.abort(); + await parked.promise.catch(() => undefined); + slow.clock.advance(30_000); + const ready = { ...slow, daemon: await slow.started }; + await sleep(100); + + expect(await leases(ready)).toEqual([]); + expect(await requestedSpecs(ready, hex(3))).toEqual([]); + expect(allocatesReceived(ready, 3)).toEqual([]); + }); + + it("a CLI that hangs up after device work started for it gets no agent-device lease: the grant that lands later is released at once and never reaches the agent-device daemon; when that device work fails and the request goes back to the queue, the front's repeated cancel removes it, and it is never granted", async () => { + const slowDevice = (clock: FakeClock) => [ + new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + knownModels: ["iPhone 16"], + latencyMs: { makeReady: 10_000 }, + platform: "ios", + }), + ]; + const rig = await setUp({ drivers: slowDevice }); + const hangUp = new AbortController(); + const first = track(allocate(rig, 1, {}, { signal: hangUp.signal })); + await sleep(50); + expect(first.done()).toBe(false); + await expect + .poll(() => rig.driver.calls.some((call) => call.operation === "makeReady")) + .toBe(true); + + hangUp.abort(); + await first.promise.catch(() => undefined); + await advance(rig, 12_000); + await expect.poll(async () => (await leases(rig)).length).toBe(0); + + expect(allocatesReceived(rig, 1)).toEqual([]); + + const failing = await setUp({ drivers: slowDevice }); + failing.driver.failOn("makeReady", 1, new Error("boot failed")); + const abort = new AbortController(); + const second = track(allocate(failing, 2, {}, { signal: abort.signal })); + await sleep(50); + expect(second.done()).toBe(false); + await expect + .poll(() => failing.driver.calls.some((call) => call.operation === "makeReady")) + .toBe(true); + abort.abort(); + await second.promise.catch(() => undefined); + + await advance(failing, 15_000); + + await expect.poll(async () => (await waiting(failing)).length).toBe(0); + expect(await leases(failing)).toEqual([]); + expect(allocatesReceived(failing, 2)).toEqual([]); + }); + + it("`DELETE /v1/lease-requests/:id` for the front's waiting request answers the allocate `refused by simlock:` with simlock's message, and nothing is leased", async () => { + const rig = await setUp({ maxRunning: 1 }); + await granted(rig, 1, {}, { token: secrets.two }); + const second = track(allocate(rig, 2)); + await expect.poll(async () => (await waiting(rig)).length).toBe(1); + const [request] = await waiting(rig); + + const cancelled = await fetch(`${rig.origin}/v1/lease-requests/${request?.id}`, { + headers: { authorization: `Bearer ${secrets.one}` }, + method: "DELETE", + }); + const answer = await envelopeOf(await second.promise); + + expect(cancelled.status).toBe(204); + expect(answer.error?.message).toMatch(/^refused by simlock: .+/); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + expect(await waiting(rig)).toEqual([]); + }); + + it("stopping the daemon while an allocate waits ends with no simlock lease and no lease request stored", async () => { + const rig = await setUp({ maxRunning: 1 }); + await granted(rig, 1, {}, { token: secrets.two }); + const second = track(allocate(rig, 2)); + await expect.poll(async () => (await waiting(rig)).length).toBe(1); + + await rig.daemon.stop("test"); + await second.promise.catch(() => undefined); + + const state = await rig.filesystem.readFile(rig.statePath); + expect(second.done()).toBe(true); + expect(state).toContain(hex(1)); + expect(state).not.toContain(hex(2)); + }); + + it("an allocate still waiting 330 s after the front admitted it answers `timed out waiting for a device; nothing was leased`, and simlock's queue is empty", async () => { + const rig = await setUp({ maxRunning: 1 }); + await granted(rig, 1, {}, { token: secrets.two }); + const second = track(allocate(rig, 2)); + await expect.poll(async () => (await waiting(rig)).length).toBe(1); + + await advance(rig, 329_000); + expect(second.done()).toBe(false); + await advance(rig, 1_000); + const answer = await envelopeOf(await second.promise); + + expect(answer.error).toMatchObject(refusal(queueTimeoutMessage, "COMMAND_FAILED")); + expect(await waiting(rig)).toEqual([]); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + }); + + it("an agent-device allocate that answers an error, and a grant that names no device address, release the simlock lease, and the CLI gets the error", async () => { + const rig = await setUp(); + rig.fake.failNextAllocate({ code: "NO_DEVICE", message: "no such device" }); + + const answer = await envelopeOf(await allocate(rig, 1)); + + expect(answer.error).toMatchObject({ + data: { code: "NO_DEVICE", message: "no such device" }, + message: "no such device", + }); + await expect.poll(async () => (await leases(rig)).length).toBe(0); + + class NoAddressDriver extends FakeDriver { + override async makeReady(...arguments_: Parameters) { + const ready = await super.makeReady(...arguments_); + return { ...ready, address: undefined as unknown as string }; + } + } + const bare = await setUp({ + drivers: (clock) => [ + new NoAddressDriver({ + availableOsVersions: ["26.5"], + clock, + knownModels: ["iPhone 16"], + platform: "ios", + }), + ], + }); + + const noAddress = await envelopeOf(await allocate(bare, 2)); + + expect(noAddress.error?.data?.code).toBe("COMMAND_FAILED"); + await expect.poll(async () => (await leases(bare)).length).toBe(0); + expect(allocatesReceived(bare, 2)).toEqual([]); + }); + + it("an agent-device allocate with no usable reply (an unreachable daemon, a non-JSON body) releases the simlock lease and answers `COMMAND_FAILED`; when agent-device answers the follow-up release with a scope error, the simlock lease stays until its TTL, and when it answers `LEASE_NOT_FOUND`, the simlock lease is released", async () => { + const rig = await setUp(); + expect((await envelopeOf(await allocate(rig, 1))).result).toMatchObject({ ok: true }); + rig.fake.setUnreachable(true); + const unreachable = await envelopeOf(await allocate(rig, 2)); + rig.fake.setUnreachable(false); + + expect(unreachable.error?.data?.code).toBe("COMMAND_FAILED"); + await expect.poll(async () => (await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + + rig.fake.answerNextAllocateWith("bad gateway"); + const notJson = await envelopeOf(await allocate(rig, 3)); + + expect(notJson.error?.data?.code).toBe("COMMAND_FAILED"); + await expect.poll(async () => (await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + + rig.fake.answerNextAllocateWith("bad gateway"); + rig.fake.failNextRelease({ code: "UNAUTHORIZED", message: "lease scope mismatch" }); + const kept = await envelopeOf(await allocate(rig, 4, { ttlMs: 60_000 })); + + expect(kept.error?.data?.code).toBe("COMMAND_FAILED"); + expect((await leases(rig)).map((lease) => lease.id)).toContain(hex(4)); + await advance(rig, 61_000); + await expect + .poll(async () => (await leases(rig)).map((lease) => lease.id)) + .not.toContain(hex(4)); + }); + + it("a CLI that hangs up while agent-device makes its lease leaves neither lease: the agent-device lease is released with the reply's scope, then the simlock lease; when that release fails, the simlock lease stays until its TTL", async () => { + const rig = await setUp(); + rig.fake.holdAllocate(); + const hangUp = new AbortController(); + const first = track(allocate(rig, 1, {}, { signal: hangUp.signal })); + await sleep(50); + expect(first.done()).toBe(false); + await expect.poll(() => allocatesReceived(rig, 1).length).toBe(1); + + hangUp.abort(); + await first.promise.catch(() => undefined); + rig.fake.letAllocateGo(); + + await expect.poll(async () => (await leases(rig)).length).toBe(0); + expect(rig.fake.leases.has(hex(1))).toBe(false); + expect(rig.fake.rpcRequests("agent_device.lease.release")[0]?.params).toMatchObject(scope); + + rig.fake.holdAllocate(); + rig.fake.failNextRelease({ code: "COMMAND_FAILED", message: "release failed" }); + const abort = new AbortController(); + const second = track(allocate(rig, 2, { ttlMs: 60_000 }, { signal: abort.signal })); + await sleep(50); + expect(second.done()).toBe(false); + await expect.poll(() => allocatesReceived(rig, 2).length).toBe(1); + abort.abort(); + await second.promise.catch(() => undefined); + rig.fake.letAllocateGo(); + await expect.poll(() => rig.fake.rpcRequests("agent_device.lease.release").length).toBe(2); + + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(2)]); + await advance(rig, 61_000); + await expect.poll(async () => (await leases(rig)).length).toBe(0); + }); + + it("a heartbeat reaches the agent-device daemon unchanged, its reply reaches the CLI, and the simlock lease's deadline does not move", async () => { + const rig = await setUp(); + await granted(rig, 1); + const [before] = await leases(rig); + rig.clock.advance(10_000); + const params = { ...scope, leaseId: hex(1), ttlMs: 90_000 }; + + const answer = await envelopeOf( + await rpc(rig, "agent_device.lease.heartbeat", params, { id: "hb-1" }), + ); + + expect(answer).toMatchObject({ id: "hb-1", result: { ok: true } }); + expect(rig.fake.rpcRequests("agent_device.lease.heartbeat")[0]?.params).toMatchObject(params); + expect((await leases(rig))[0]).toEqual(before); + }); + + it("release, and release after `close` (which the daemon answers `released: false`), go to the agent-device daemon first, then release the simlock lease, and the device is reclaimed and free", async () => { + const rig = await setUp(); + let heldAtDaemonRelease: readonly string[] = []; + rig.fake.onRelease = async () => { + heldAtDaemonRelease = (await leases(rig)).map((lease) => lease.id); + }; + await granted(rig, 1); + + const released = await envelopeOf(await release(rig, 1)); + + expect(released.result).toMatchObject({ ok: true, released: true }); + expect(heldAtDaemonRelease).toEqual([hex(1)]); + expect(await leases(rig)).toEqual([]); + await expect + .poll(async () => (await devices(rig)).map((device) => device.state)) + .toEqual(["ready"]); + + await granted(rig, 2); + await command(rig, "close", 2); + const afterClose = await envelopeOf(await release(rig, 2)); + + expect(afterClose.result).toMatchObject({ ok: true, released: false }); + expect(heldAtDaemonRelease).toEqual([hex(2)]); + expect(await leases(rig)).toEqual([]); + await expect + .poll(async () => (await devices(rig)).map((device) => device.state)) + .toEqual(["ready"]); + }); + + it("a release the agent-device daemon fails keeps the simlock lease and answers the daemon's error", async () => { + const rig = await setUp(); + await granted(rig, 1); + rig.fake.failNextRelease({ code: "COMMAND_FAILED", message: "daemon could not release" }); + + const answer = await envelopeOf(await release(rig, 1)); + + expect(answer.error).toMatchObject({ + data: { code: "COMMAND_FAILED" }, + message: "daemon could not release", + }); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + }); + + it("a release after `simlock release --all` ended the simlock lease answers the daemon's reply", async () => { + const rig = await setUp(); + await granted(rig, 1); + await rig.daemon.dispatch("lease.release-all", {}, adminSession); + expect(await leases(rig)).toEqual([]); + + const answer = await envelopeOf(await release(rig, 1)); + + expect(answer.result).toMatchObject({ ok: true, released: true }); + expect(rig.fake.leases.has(hex(1))).toBe(false); + }); + + it("a release from a second token naming the first token's lease ID leaves the first token's simlock lease in place", async () => { + const rig = await setUp(); + await granted(rig, 1); + + const answer = await envelopeOf(await release(rig, 1, { token: secrets.two })); + + expect(answer.error).toBeDefined(); + expect(rig.fake.rpcRequests("agent_device.lease.release")).toHaveLength(1); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); + expect(rig.fake.leases.has(hex(1))).toBe(true); + }); + + it("a command's reply, a streamed progress body included, reaches the CLI unchanged, and a CLI that hangs up during it aborts the request the agent-device daemon was answering", async () => { + const rig = await setUp(); + const chunks = ['{"progress":1}\n', '{"progress":2}\n', '{"done":true}\n']; + rig.fake.streamNextCommand(chunks); + + const streamed = await command(rig, "install", undefined); + + expect(await streamed.text()).toBe(chunks.join("")); + + const plain = await envelopeOf(await command(rig, "snapshot", undefined)); + expect(plain.result).toMatchObject({ command: "snapshot", ok: true }); + + rig.fake.holdCommand(); + const hangUp = new AbortController(); + const held = track(command(rig, "snapshot", undefined, { signal: hangUp.signal })); + await sleep(50); + expect(held.done()).toBe(false); + await expect.poll(() => rig.fake.rpcRequests("agent_device.command").length).toBe(3); + hangUp.abort(); + await held.promise.catch(() => undefined); + + await expect.poll(() => rig.fake.abortedCommands).toEqual(["snapshot"]); + }); + + it("an upload preflight answers a ticket URL under `/v1/agent-device/upload/direct/` on simlock's own origin, carrying the agent's own simlock token, and a `PUT` to it reaches the agent-device daemon", async () => { + const rig = await setUp(); + + const preflight = await send(rig, "/upload/preflight", { body: "{}" }); + expect(preflight.status).toBe(200); + const ticket = ( + (await preflight.json()) as { upload: { url: string; headers: Record } } + ).upload; + + expect(ticket.url.startsWith(`${rig.origin}/v1/agent-device/upload/direct/`)).toBe(true); + const carried = Object.entries(ticket.headers).find( + ([name]) => name.toLowerCase() === "authorization", + ); + expect(carried?.[1]).toBe(`Bearer ${secrets.one}`); + + const put = await fetch(ticket.url, { + body: "artifact bytes", + headers: ticket.headers, + method: "PUT", + }); + + expect(put.status).toBe(200); + expect(rig.fake.uploads).toEqual(["artifact bytes"]); + }); + + it("an `/rpc` body over 1 MiB, one that is not JSON, a JSON array, and an object with no `method` are each refused with their message and `id: null`, also while agent-device is unavailable, and neither simlock nor the agent-device daemon sees them", async () => { + const rig = await setUp(); + expect((await command(rig, "snapshot", undefined)).status).toBe(200); + const oneMiB = 1024 * 1024; + const bodies: readonly [string, string][] = [ + [ + JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "agent_device.command", + params: { pad: "x".repeat(oneMiB) }, + }), + "refused: request body over 1 MiB", + ], + ["{ not json", "refused: send one JSON-RPC request"], + ["[]", "refused: send one JSON-RPC request"], + [JSON.stringify({ id: 3, jsonrpc: "2.0" }), "refused: send one JSON-RPC request"], + ]; + + for (const unavailable of [false, true]) { + rig.fake.setUnavailable(unavailable ? "starting" : undefined); + const receivedBefore = rig.fake.requests.length; + for (const [body, message] of bodies) { + const answer = await envelopeOf(await send(rig, "/rpc", { body })); + expect(answer).toMatchObject({ error: refusal(message), id: null, jsonrpc: "2.0" }); + } + expect(rig.fake.requests).toHaveLength(receivedBefore); + } + expect(await waiting(rig)).toEqual([]); + expect(await leases(rig)).toEqual([]); + }); +}); diff --git a/src/agent-device/front/front-map.test.ts b/src/agent-device/front/front-map.test.ts new file mode 100644 index 000000000..4fde460b2 --- /dev/null +++ b/src/agent-device/front/front-map.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { TokenIdentity } from "../../http/token-store.js"; +import { FakeClock } from "../../ports/index.js"; +import { FrontMap } from "./front-map.js"; + +const agent: TokenIdentity = { requesterId: "tok_a", role: "agent" }; +const ttlMs = 60_000; + +function entry(deadline: number) { + return { address: "device-addr", deadline, identity: agent, ttlMs }; +} + +describe("the front map", () => { + it("drops an ended entry one TTL after it ended, at the next read or write", () => { + const clock = new FakeClock(1_000); + const map = new FrontMap(clock); + map.add("lease-a", entry(70_000)); + map.markEnded("lease-a"); + + clock.advance(ttlMs - 1); + expect(map.get("lease-a")).toMatchObject({ endedAt: 1_000 }); + + clock.advance(1); + map.add("lease-b", entry(500_000)); + expect(map.get("lease-a")).toBeUndefined(); + expect(map.get("lease-b")).toBeDefined(); + expect(clock.pendingTimerCount).toBe(0); + }); + + it("at an allocate marks an entry past its latest deadline ended when lease.list no longer shows its lease, and lets one it still shows take the listed deadline", async () => { + const clock = new FakeClock(1_000); + const map = new FrontMap(clock); + map.add("gone", entry(2_000)); + map.add("kept", entry(2_000)); + map.add("fresh", entry(9_000_000)); + clock.advance(10_000); + const list = vi.fn(async () => [{ id: "kept", ttlDeadline: 500_000 }]); + + await map.refresh(list); + + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith(agent); + expect(map.get("gone")?.endedAt).toBe(11_000); + expect(map.get("kept")).toMatchObject({ deadline: 500_000 }); + expect(map.get("kept")?.endedAt).toBeUndefined(); + expect(map.get("fresh")).toMatchObject({ deadline: 9_000_000 }); + expect(map.get("fresh")?.endedAt).toBeUndefined(); + }); +}); diff --git a/src/agent-device/front/front-map.ts b/src/agent-device/front/front-map.ts new file mode 100644 index 000000000..f82aae657 --- /dev/null +++ b/src/agent-device/front/front-map.ts @@ -0,0 +1,30 @@ +import type { TokenIdentity } from "../../http/token-store.js"; +import type { Clock } from "../../ports/index.js"; + +export interface FrontMapEntry { + readonly identity: TokenIdentity; + readonly address: string; + readonly ttlMs: number; + readonly deadline: number; +} + +export interface ListedLease { + readonly id: string; + readonly ttlDeadline: number; +} + +export class FrontMap { + constructor(readonly clock: Clock) {} + + add(_leaseId: string, _entry: FrontMapEntry): void {} + + get(_leaseId: string): (FrontMapEntry & { readonly endedAt?: number }) | undefined { + return undefined; + } + + markEnded(_leaseId: string): void {} + + async refresh( + _list: (identity: TokenIdentity) => Promise, + ): Promise {} +} diff --git a/src/agent-device/host/host.ts b/src/agent-device/host/host.ts new file mode 100644 index 000000000..0ccbcabed --- /dev/null +++ b/src/agent-device/host/host.ts @@ -0,0 +1,12 @@ +import type { TokenIdentity } from "../../http/token-store.js"; +import type { AgentDeviceUpstream } from "./upstream.js"; + +export class AgentDeviceHost { + constructor(readonly upstream: AgentDeviceUpstream) {} + + tag(_request: Request, _identity: TokenIdentity): void {} + + authenticate(_request: Request): { readonly tenantId: string } | null { + return null; + } +} diff --git a/src/agent-device/host/upstream.test.ts b/src/agent-device/host/upstream.test.ts new file mode 100644 index 000000000..0363f8e83 --- /dev/null +++ b/src/agent-device/host/upstream.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + FakeClock, + FakeProcessSupervisor, + FakeTcpProbe, + JsonLinesLogger, + MemoryFilesystem, + MemoryLogSink, + ScriptedProcessRunner, + type ScriptedProcessExpectation, +} from "../../ports/index.js"; +import { FakeAgentDeviceHealth } from "../testing.js"; +import { agentDeviceLeaseMaxTtlMs } from "../shared/daemon-environment.js"; +import { AgentDeviceSupervisor } from "./agent-device-supervisor.js"; +import { AgentDeviceHost } from "./host.js"; +import { SupervisorAgentDeviceUpstream, type AgentDeviceUpstream } from "./upstream.js"; + +const home = "/home/simlock/.simlock"; +const nodePath = "/opt/node/bin/node"; +const packageRoot = "/opt/ad/node_modules/agent-device"; +const realCli = `${packageRoot}/bin/agent-device.mjs`; +const internalEntry = `${packageRoot}/dist/src/internal/daemon.js`; +const httpPort = 4310; +const daemonToken = "daemon-token"; +const leaseMaxTtlMs = 4 * 60 * 60_000; + +async function arrange(version: string) { + const clock = new FakeClock(1_000_000); + const filesystem = new MemoryFilesystem(); + const probe = new FakeTcpProbe(); + const health = new FakeAgentDeviceHealth(); + health.respondWith({ rpcProtocolVersion: 2, service: "agent-device-daemon" }); + await filesystem.mkdirp(home); + await filesystem.mkdirp("/usr/local/bin"); + await filesystem.mkdirp(`${packageRoot}/bin`); + await filesystem.mkdirp(`${packageRoot}/dist/src/internal`); + await filesystem.writeFileAtomic(realCli, "#!/usr/bin/env node\n"); + filesystem.defineAttributes(realCli, { mode: 0o755 }); + await filesystem.writeFileAtomic(`${packageRoot}/package.json`, '{"name":"agent-device"}'); + await filesystem.writeFileAtomic(internalEntry, ""); + filesystem.defineSymlink("/usr/local/bin/agent-device", realCli); + const processes = new FakeProcessSupervisor([]); + const expectations: ScriptedProcessExpectation[] = [ + { + match: { args: [realCli, "--version"], command: nodePath }, + result: { code: 0, stderr: "", stdout: `${version}\n` }, + }, + { + hangs: true, + match: { args: [internalEntry, `--simlock-home=${home}`], command: nodePath }, + }, + ]; + const runner = new ScriptedProcessRunner(expectations); + const spawn = runner.spawn.bind(runner); + vi.spyOn(runner, "spawn").mockImplementation((command, args, options) => { + const handle = spawn(command, args, options); + if (command !== nodePath || args[0] !== internalEntry) return handle; + processes.markAlive(handle.pid); + void filesystem.writeFileAtomic( + `${home}/agent-device/daemon.json`, + JSON.stringify({ httpPort, pid: handle.pid, token: daemonToken, version }), + ); + probe.startListening(httpPort); + return handle; + }); + const supervisor = new AgentDeviceSupervisor({ + baseEnvironment: { HOME: "/home/operator", PATH: "/usr/local/bin:/usr/bin" }, + clock, + config: { command: "agent-device", enabled: true }, + driverEnvironment: {}, + filesystem, + healthProbe: health, + home, + leaseMaxTtlMs, + logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), + nodePath, + nodeVersion: "22.14.0", + platforms: ["ios", "android"], + processRunner: runner, + processSupervisor: processes, + tcpProbe: probe, + }); + return { clock, supervisor, upstream: new SupervisorAgentDeviceUpstream(supervisor) }; +} + +async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function started(harness: Awaited>): Promise { + let settled = false; + const starting = harness.supervisor.start().finally(() => { + settled = true; + }); + starting.catch(() => undefined); + for (let tick = 0; tick < 4_000 && !settled; tick += 1) { + await flush(); + harness.clock.advance(100); + } + await starting; +} + +describe("the agent-device upstream adapter over the supervisor", () => { + it("reports running with the supervisor's base URL, token, generation and the ceiling", async () => { + const harness = await arrange("0.21.25-dev"); + await started(harness); + const availability = harness.supervisor.availability(); + if (availability.state !== "running") throw new Error(`got ${availability.state}`); + + expect(harness.upstream.current()).toEqual({ + baseUrl: availability.baseUrl, + generation: availability.generation, + leaseMaxTtlMs: agentDeviceLeaseMaxTtlMs(leaseMaxTtlMs), + state: "running", + token: daemonToken, + }); + expect(availability.baseUrl).toContain(String(httpPort)); + }); + + it("reports unavailable with the supervisor's message", async () => { + const harness = await arrange("0.20.3"); + await started(harness); + + expect(harness.upstream.current()).toEqual({ + reason: "found 0.20.3, need 0.21.25 or later", + state: "unavailable", + }); + }); + + it("reports unavailable with starting while the supervisor starts", async () => { + const harness = await arrange("0.21.25-dev"); + const starting = harness.supervisor.start(); + starting.catch(() => undefined); + + expect(harness.upstream.current()).toEqual({ reason: "starting", state: "unavailable" }); + }); +}); + +describe("the agent-device host's authenticate", () => { + const upstream: AgentDeviceUpstream = { + current: () => ({ reason: "starting", state: "unavailable" }), + fetch: async () => new Response(null), + }; + + it("returns the tagged identity's principal as the tenant, and null for a request the front did not tag", () => { + const host = new AgentDeviceHost(upstream); + const tagged = new Request("http://worker/v1/agent-device/rpc"); + host.tag(tagged, { requesterId: "tok_agent", role: "agent" }); + + expect(host.authenticate(tagged)).toEqual({ tenantId: "tok_agent" }); + expect(host.authenticate(new Request("http://worker/v1/agent-device/rpc"))).toBeNull(); + }); +}); diff --git a/src/agent-device/host/upstream.ts b/src/agent-device/host/upstream.ts new file mode 100644 index 000000000..37b61fdaa --- /dev/null +++ b/src/agent-device/host/upstream.ts @@ -0,0 +1,28 @@ +import type { AgentDeviceAvailability } from "./agent-device-supervisor.js"; + +export type AgentDeviceUpstreamState = + | { + readonly state: "running"; + readonly baseUrl: string; + readonly token: string; + readonly generation: number; + readonly leaseMaxTtlMs: number; + } + | { readonly state: "unavailable"; readonly reason: string }; + +export interface AgentDeviceUpstream { + current(): AgentDeviceUpstreamState; + fetch(request: Request): Promise; +} + +export class SupervisorAgentDeviceUpstream implements AgentDeviceUpstream { + constructor(readonly supervisor: { availability(): AgentDeviceAvailability }) {} + + current(): AgentDeviceUpstreamState { + return { reason: "not implemented", state: "unavailable" }; + } + + async fetch(request: Request): Promise { + return globalThis.fetch(request); + } +} diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index 7d711624c..e5c10a8f4 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -2,4 +2,5 @@ export { agentDeviceFindings } from "./host/availability-findings.js"; export { AgentDeviceSupervisor } from "./host/agent-device-supervisor.js"; // fallow-ignore-next-line unused-type -- the availability contract the front (#472) and doctor (#471) read. export type { AgentDeviceAvailability } from "./host/agent-device-supervisor.js"; +export type { AgentDeviceUpstream } from "./host/upstream.js"; export { type AgentDeviceHealthProbe, NodeAgentDeviceHealthProbe } from "./host/health.js"; diff --git a/src/agent-device/shared/rpc.test.ts b/src/agent-device/shared/rpc.test.ts new file mode 100644 index 000000000..ff964b359 --- /dev/null +++ b/src/agent-device/shared/rpc.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { commandLeaseId } from "./rpc.js"; + +const first = "a".repeat(32); +const second = "b".repeat(32); + +describe("a command's lease ID", () => { + it("is read from params.meta.leaseId, else params.flags.leaseId, and a value that is not 32 lowercase hex is no lease ID", () => { + expect(commandLeaseId({ meta: { leaseId: first } })).toBe(first); + expect(commandLeaseId({ flags: { leaseId: second } })).toBe(second); + expect(commandLeaseId({ flags: { leaseId: second }, meta: { leaseId: first } })).toBe(first); + + expect(commandLeaseId({ meta: { leaseId: first.toUpperCase() } })).toBeUndefined(); + expect(commandLeaseId({ meta: { leaseId: first.slice(1) } })).toBeUndefined(); + expect(commandLeaseId({ meta: { leaseId: `${first}0` } })).toBeUndefined(); + expect(commandLeaseId({ meta: { leaseId: `${"g".repeat(32)}` } })).toBeUndefined(); + expect(commandLeaseId({ meta: { leaseId: 7 } })).toBeUndefined(); + expect(commandLeaseId({})).toBeUndefined(); + }); +}); diff --git a/src/agent-device/shared/rpc.ts b/src/agent-device/shared/rpc.ts new file mode 100644 index 000000000..fc7cfd7a8 --- /dev/null +++ b/src/agent-device/shared/rpc.ts @@ -0,0 +1,3 @@ +export function commandLeaseId(_params: unknown): string | undefined { + return undefined; +} diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index a80d577d8..6951a7c99 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -1,4 +1,5 @@ import type { AgentDeviceHealthProbe } from "./host/health.js"; +import type { AgentDeviceUpstream, AgentDeviceUpstreamState } from "./host/upstream.js"; type Answer = { readonly body: unknown } | { readonly error: Error } | "never"; @@ -22,3 +23,281 @@ export class FakeAgentDeviceHealth implements AgentDeviceHealthProbe { return this.#answer.body; } } + +export interface FakeAgentDeviceRequest { + readonly method: string; + readonly path: string; + readonly tenant: string | undefined; + readonly rpcMethod?: string; + readonly params?: Readonly>; +} + +export interface FakeAgentDeviceLease { + readonly leaseId: string; + readonly tenant: string | undefined; + readonly scope: { + readonly runId: unknown; + readonly leaseProvider: unknown; + readonly clientId: unknown; + readonly deviceKey: unknown; + }; + readonly ttlMs: unknown; + ended: boolean; +} + +interface FakeFailure { + readonly code: string; + readonly message: string; +} + +interface Gate { + readonly promise: Promise; + open(): void; +} + +function gate(): Gate { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { open, promise }; +} + +function envelope(id: unknown, result: unknown): Response { + return Response.json({ id, jsonrpc: "2.0", result }); +} + +function errorEnvelope(id: unknown, failure: FakeFailure): Response { + return Response.json({ + error: { code: -32000, data: failure, message: failure.message }, + id, + jsonrpc: "2.0", + }); +} + +export class FakeAgentDevice implements AgentDeviceUpstream { + readonly requests: FakeAgentDeviceRequest[] = []; + readonly leases = new Map(); + readonly abortedCommands: string[] = []; + readonly uploads: string[] = []; + baseUrl = "http://127.0.0.1:47001"; + token = "daemon-token-1"; + generation = 1; + leaseMaxTtlMs: number; + onRelease: (() => Promise) | undefined; + #unavailable: string | undefined; + #unreachable = false; + #failAllocate: FakeFailure | undefined; + #failRelease: FakeFailure | undefined; + #allocateBody: string | undefined; + #allocateGate: Gate | undefined; + #commandGate: Gate | undefined; + #streamChunks: readonly string[] | undefined; + #restarts = 0; + + constructor(options: { readonly leaseMaxTtlMs?: number } = {}) { + this.leaseMaxTtlMs = options.leaseMaxTtlMs ?? 4 * 60 * 60_000 + 10_000; + } + + current(): AgentDeviceUpstreamState { + if (this.#unavailable !== undefined) { + return { reason: this.#unavailable, state: "unavailable" }; + } + return { + baseUrl: this.baseUrl, + generation: this.generation, + leaseMaxTtlMs: this.leaseMaxTtlMs, + state: "running", + token: this.token, + }; + } + + setUnavailable(reason: string | undefined): void { + this.#unavailable = reason; + } + + setUnreachable(unreachable: boolean): void { + this.#unreachable = unreachable; + } + + failNextAllocate(failure: FakeFailure): void { + this.#failAllocate = failure; + } + + failNextRelease(failure: FakeFailure): void { + this.#failRelease = failure; + } + + answerNextAllocateWith(body: string): void { + this.#allocateBody = body; + } + + holdAllocate(): void { + this.#allocateGate = gate(); + } + + letAllocateGo(): void { + this.#allocateGate?.open(); + } + + holdCommand(): void { + this.#commandGate = gate(); + } + + letCommandGo(): void { + this.#commandGate?.open(); + } + + streamNextCommand(chunks: readonly string[]): void { + this.#streamChunks = chunks; + } + + restart(): void { + this.#restarts += 1; + this.generation += 1; + this.baseUrl = `http://127.0.0.1:${47_001 + this.#restarts}`; + this.token = `daemon-token-${this.generation}`; + this.leases.clear(); + } + + rpcRequests(rpcMethod: string): readonly FakeAgentDeviceRequest[] { + return this.requests.filter((request) => request.rpcMethod === rpcMethod); + } + + async fetch(request: Request): Promise { + if (this.#unreachable) throw new TypeError("fetch failed"); + const url = new URL(request.url); + const base = new URL(this.baseUrl); + if (url.origin !== base.origin) throw new TypeError("fetch failed: wrong origin"); + if (request.headers.get("authorization") !== `Bearer ${this.token}`) { + return Response.json( + { code: "UNAUTHORIZED", error: "bad token", ok: false }, + { status: 401 }, + ); + } + const tenantHeader = request.headers.get("x-agent-device-tenant") ?? undefined; + if (url.pathname === "/health") { + this.requests.push({ method: request.method, path: url.pathname, tenant: tenantHeader }); + return Response.json({ ok: true, rpcProtocolVersion: 2, service: "agent-device-daemon" }); + } + if (url.pathname === "/upload/preflight") { + this.requests.push({ method: request.method, path: url.pathname, tenant: tenantHeader }); + return Response.json({ + ok: true, + upload: { headers: {}, url: `${this.baseUrl}/upload/direct/ticket-1` }, + }); + } + if (url.pathname.startsWith("/upload/direct/")) { + this.requests.push({ method: request.method, path: url.pathname, tenant: tenantHeader }); + this.uploads.push(await request.text()); + return Response.json({ ok: true }); + } + if (url.pathname !== "/rpc") return new Response("Not found", { status: 404 }); + return this.#rpc(request, tenantHeader); + } + + async #rpc(request: Request, tenantHeader: string | undefined): Promise { + const body = (await request.json()) as { + readonly id?: unknown; + readonly method?: string; + readonly params?: Record; + }; + const params = body.params ?? {}; + const meta = (params["meta"] ?? {}) as Record; + const tenantOf = (value: unknown) => (typeof value === "string" ? value : undefined); + const tenant = tenantHeader ?? tenantOf(params["tenantId"]) ?? tenantOf(meta["tenantId"]); + const method = body.method ?? ""; + this.requests.push({ method: request.method, params, path: "/rpc", rpcMethod: method, tenant }); + const kind = method.replace(/^agent[_-]device\./, ""); + if (kind === "lease.allocate") return this.#allocate(body.id, params, tenant); + if (kind === "lease.heartbeat") return envelope(body.id, { lease: params, ok: true }); + if (kind === "lease.release") { + await this.onRelease?.(); + return this.#release(body.id, params, tenant); + } + return this.#command(request, body.id, params, meta); + } + + async #allocate( + id: unknown, + params: Record, + tenant: string | undefined, + ): Promise { + if (this.#allocateGate !== undefined) await this.#allocateGate.promise; + const raw = this.#allocateBody; + this.#allocateBody = undefined; + if (raw !== undefined) return new Response(raw, { status: 200 }); + const failure = this.#failAllocate; + this.#failAllocate = undefined; + if (failure !== undefined) return errorEnvelope(id, failure); + const leaseId = String(params["leaseId"]); + const scope = { + clientId: params["clientId"], + deviceKey: params["deviceKey"], + leaseProvider: params["leaseProvider"], + runId: params["runId"], + }; + this.leases.set(leaseId, { ended: false, leaseId, scope, tenant, ttlMs: params["ttlMs"] }); + return envelope(id, { + lease: { ...scope, leaseId, tenantId: tenant, ttlMs: params["ttlMs"] }, + ok: true, + }); + } + + #release(id: unknown, params: Record, tenant: string | undefined): Response { + const failure = this.#failRelease; + this.#failRelease = undefined; + if (failure !== undefined) return errorEnvelope(id, failure); + const lease = this.leases.get(String(params["leaseId"])); + if (lease === undefined || lease.tenant !== tenant) { + return errorEnvelope(id, { code: "LEASE_NOT_FOUND", message: "lease not found" }); + } + if ( + lease.scope.runId !== params["runId"] || + lease.scope.leaseProvider !== params["leaseProvider"] || + lease.scope.clientId !== params["clientId"] + ) { + return errorEnvelope(id, { code: "UNAUTHORIZED", message: "lease scope mismatch" }); + } + this.leases.delete(lease.leaseId); + return envelope(id, { ok: true, released: !lease.ended }); + } + + async #command( + request: Request, + id: unknown, + params: Record, + meta: Record, + ): Promise { + const command = String(params["command"] ?? ""); + if (this.#commandGate !== undefined) { + const aborted = new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => { + this.abortedCommands.push(command); + reject(new Error("aborted")); + }); + }); + await Promise.race([this.#commandGate.promise, aborted]); + } + const chunks = this.#streamChunks; + this.#streamChunks = undefined; + if (chunks !== undefined) { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), + { headers: { "content-type": "application/x-ndjson" } }, + ); + } + const leaseId = meta["leaseId"]; + if (command === "close" && typeof leaseId === "string") { + const lease = this.leases.get(leaseId); + if (lease !== undefined) lease.ended = true; + } + return envelope(id, { command, ok: true }); + } +} diff --git a/src/daemon/main.ts b/src/daemon/main.ts index ad5096a29..509ff74aa 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -6,6 +6,7 @@ import { agentDeviceFindings, AgentDeviceSupervisor, type AgentDeviceHealthProbe, + type AgentDeviceUpstream, NodeAgentDeviceHealthProbe, } from "../agent-device/index.js"; import { EVENT_FILE_NAME, EventBus, EventHistory, type EventBusLogger } from "../bus/index.js"; @@ -108,6 +109,7 @@ import { createCredentialRoleResolver } from "./session.js"; export interface StartDaemonOptions { readonly agentDeviceHealth?: AgentDeviceHealthProbe; + readonly agentDeviceUpstream?: AgentDeviceUpstream; readonly clock?: Clock; readonly configOverrides?: ConfigOverrides; readonly configPath?: string; From 65f336a196aa5a1328260d15bfe4cd3fb3762da5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:34:40 +0200 Subject: [PATCH 02/10] test: an agent opens and closes a device through simlock (#472), fallow fixes --- src/agent-device/edge.test.ts | 57 ++++++++++++++++--------------- src/agent-device/host/upstream.ts | 2 ++ src/agent-device/testing.ts | 15 ++++---- 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index 74590efda..d9f9520f3 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -354,6 +354,18 @@ async function requestedSpecs(rig: Rig, requester: string): Promise payload.requestSpec); } +async function grantedDevice(rig: Rig) { + const [device] = await devices(rig); + expect(device?.address).toBeDefined(); + return device as { readonly address: string; readonly driverDeviceId?: string }; +} + +function receivedAllocate(rig: Rig, n: number): Record { + const params = allocatesReceived(rig, n)[0]?.params; + expect(params).toBeDefined(); + return params as Record; +} + function allocatesReceived(rig: Rig, n?: number) { return rig.fake .rpcRequests("agent_device.lease.allocate") @@ -693,39 +705,30 @@ describe("the agent-device front, through the whole worker daemon", () => { it("after the grant, the allocate the agent-device daemon receives names the granted device's address by `udid` on iOS and by `serial` on Android (the emulator serial, not the AVD name), and carries no `device`, `deviceKey` or `target`; the CLI gets the daemon's reply with the same lease ID", async () => { const ios = await setUp(); - const iosAnswer = await allocate(ios, 1, { - device: "iPhone 17 Pro", - deviceKey: "saved", - target: "mobile", - }); - expect((await envelopeOf(iosAnswer)).result).toMatchObject({ ok: true }); - const [iosDevice] = await devices(ios); - const iosParams = allocatesReceived(ios, 1)[0]?.params; + const iosAnswer = await envelopeOf( + await allocate(ios, 1, { device: "iPhone 17 Pro", deviceKey: "saved", target: "mobile" }), + ); + const iosDevice = await grantedDevice(ios); + const iosParams = receivedAllocate(ios, 1); - expect(iosDevice?.address).toBeDefined(); - expect(iosParams?.["udid"]).toBe(iosDevice?.address); + expect(iosAnswer.result).toMatchObject({ lease: { leaseId: hex(1) }, ok: true }); + expect(iosParams["udid"]).toBe(iosDevice.address); expect(iosParams).not.toHaveProperty("serial"); - for (const dropped of ["device", "deviceKey", "target"]) + for (const dropped of ["device", "deviceKey", "target"]) { expect(iosParams).not.toHaveProperty(dropped); - expect((await envelopeOf(iosAnswer)).result).toMatchObject({ - lease: { leaseId: hex(1) }, - ok: true, - }); + } const android = await setUp({ drivers: (clock) => [androidDriver(clock)] }); - const androidAnswer = await allocate(android, 2, { device: "Pixel 8", platform: "android" }); - expect((await envelopeOf(androidAnswer)).result).toMatchObject({ ok: true }); - const [androidDevice] = await devices(android); - const androidParams = allocatesReceived(android, 2)[0]?.params; - - expect(androidDevice?.address).toBeDefined(); - expect(androidParams?.["serial"]).toBe(androidDevice?.address); - expect(androidParams?.["serial"]).not.toBe(androidDevice?.driverDeviceId); + const androidAnswer = await envelopeOf( + await allocate(android, 2, { device: "Pixel 8", platform: "android" }), + ); + const androidDevice = await grantedDevice(android); + const androidParams = receivedAllocate(android, 2); + + expect(androidAnswer.result).toMatchObject({ lease: { leaseId: hex(2) }, ok: true }); + expect(androidParams["serial"]).toBe(androidDevice.address); + expect(androidParams["serial"]).not.toBe(androidDevice.driverDeviceId); expect(androidParams).not.toHaveProperty("udid"); - expect((await envelopeOf(androidAnswer)).result).toMatchObject({ - lease: { leaseId: hex(2) }, - ok: true, - }); }); it("with every matching device leased, allocate waits, and is granted and answered once that device is released and reclaimed", async () => { diff --git a/src/agent-device/host/upstream.ts b/src/agent-device/host/upstream.ts index 37b61fdaa..c485a5556 100644 --- a/src/agent-device/host/upstream.ts +++ b/src/agent-device/host/upstream.ts @@ -18,10 +18,12 @@ export interface AgentDeviceUpstream { export class SupervisorAgentDeviceUpstream implements AgentDeviceUpstream { constructor(readonly supervisor: { availability(): AgentDeviceAvailability }) {} + // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. current(): AgentDeviceUpstreamState { return { reason: "not implemented", state: "unavailable" }; } + // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. async fetch(request: Request): Promise { return globalThis.fetch(request); } diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index 6951a7c99..63b57c87b 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -32,7 +32,7 @@ export interface FakeAgentDeviceRequest { readonly params?: Readonly>; } -export interface FakeAgentDeviceLease { +interface FakeAgentDeviceLease { readonly leaseId: string; readonly tenant: string | undefined; readonly scope: { @@ -63,6 +63,10 @@ function gate(): Gate { return { open, promise }; } +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + function envelope(id: unknown, result: unknown): Response { return Response.json({ id, jsonrpc: "2.0", result }); } @@ -99,6 +103,7 @@ export class FakeAgentDevice implements AgentDeviceUpstream { this.leaseMaxTtlMs = options.leaseMaxTtlMs ?? 4 * 60 * 60_000 + 10_000; } + // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. current(): AgentDeviceUpstreamState { if (this.#unavailable !== undefined) { return { reason: this.#unavailable, state: "unavailable" }; @@ -144,10 +149,6 @@ export class FakeAgentDevice implements AgentDeviceUpstream { this.#commandGate = gate(); } - letCommandGo(): void { - this.#commandGate?.open(); - } - streamNextCommand(chunks: readonly string[]): void { this.#streamChunks = chunks; } @@ -164,6 +165,7 @@ export class FakeAgentDevice implements AgentDeviceUpstream { return this.requests.filter((request) => request.rpcMethod === rpcMethod); } + // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. async fetch(request: Request): Promise { if (this.#unreachable) throw new TypeError("fetch failed"); const url = new URL(request.url); @@ -204,8 +206,7 @@ export class FakeAgentDevice implements AgentDeviceUpstream { }; const params = body.params ?? {}; const meta = (params["meta"] ?? {}) as Record; - const tenantOf = (value: unknown) => (typeof value === "string" ? value : undefined); - const tenant = tenantHeader ?? tenantOf(params["tenantId"]) ?? tenantOf(meta["tenantId"]); + const tenant = tenantHeader ?? stringOrUndefined(params["tenantId"] ?? meta["tenantId"]); const method = body.method ?? ""; this.requests.push({ method: request.method, params, path: "/rpc", rpcMethod: method, tenant }); const kind = method.replace(/^agent[_-]device\./, ""); From 0f7c0d8f1efb7d4714c81adaf9bef19f4d021b86 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:35:03 +0200 Subject: [PATCH 03/10] test: simplify the agent-device fake (#472) --- src/agent-device/testing.ts | 41 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index 63b57c87b..18b3daaed 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -63,8 +63,17 @@ function gate(): Gate { return { open, promise }; } -function stringOrUndefined(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +async function readRpc(request: Request, tenantHeader: string | undefined) { + const body = (await request.json()) as { + readonly id?: unknown; + readonly method?: string; + readonly params?: Record; + }; + const params = body.params ?? {}; + const meta = (params["meta"] ?? {}) as Record; + const stamped = params["tenantId"] ?? meta["tenantId"]; + const tenant = tenantHeader ?? (typeof stamped === "string" ? stamped : undefined); + return { id: body.id, meta, method: body.method ?? "", params, tenant }; } function envelope(id: unknown, result: unknown): Response { @@ -199,24 +208,20 @@ export class FakeAgentDevice implements AgentDeviceUpstream { } async #rpc(request: Request, tenantHeader: string | undefined): Promise { - const body = (await request.json()) as { - readonly id?: unknown; - readonly method?: string; - readonly params?: Record; - }; - const params = body.params ?? {}; - const meta = (params["meta"] ?? {}) as Record; - const tenant = tenantHeader ?? stringOrUndefined(params["tenantId"] ?? meta["tenantId"]); - const method = body.method ?? ""; + const call = await readRpc(request, tenantHeader); + const { id, meta, method, params, tenant } = call; this.requests.push({ method: request.method, params, path: "/rpc", rpcMethod: method, tenant }); - const kind = method.replace(/^agent[_-]device\./, ""); - if (kind === "lease.allocate") return this.#allocate(body.id, params, tenant); - if (kind === "lease.heartbeat") return envelope(body.id, { lease: params, ok: true }); - if (kind === "lease.release") { - await this.onRelease?.(); - return this.#release(body.id, params, tenant); + switch (method.replace(/^agent[_-]device\./, "")) { + case "lease.allocate": + return this.#allocate(id, params, tenant); + case "lease.heartbeat": + return envelope(id, { lease: params, ok: true }); + case "lease.release": + await this.onRelease?.(); + return this.#release(id, params, tenant); + default: + return this.#command(request, id, params, meta); } - return this.#command(request, body.id, params, meta); } async #allocate( From 440530f2296f5f9882fd85a483bd56c0112fac1c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:50:13 +0200 Subject: [PATCH 04/10] feat(agent-device): the front, the host and the allocate flow (#472) 7 failing -> 1 failing The /v1/agent-device mount, the proxy host, the front map and the lease flow. Fixture fixes in edge.test.ts: phone models for the slow drivers, a long TTL for the held lease, room for 5 devices, timing gaps after a hang-up. --- docs/CONFIGURATION.md | 2 +- docs/HTTP-API.md | 66 ++++ docs/internal/COMPONENTS.md | 4 + package.json | 1 + pnpm-lock.yaml | 10 + src/agent-device/edge.test.ts | 16 +- src/agent-device/front/allocate.ts | 322 ++++++++++++++++++ src/agent-device/front/context.ts | 83 +++++ src/agent-device/front/front-map.ts | 79 ++++- src/agent-device/front/front.ts | 101 ++++++ src/agent-device/front/host-link.ts | 23 ++ src/agent-device/front/release.ts | 28 ++ src/agent-device/host/host.ts | 124 ++++++- src/agent-device/host/upstream.ts | 16 +- src/agent-device/index.ts | 29 ++ src/agent-device/shared/paths.ts | 5 + src/agent-device/shared/replies.ts | 44 +++ src/agent-device/shared/rpc.ts | 137 +++++++- src/daemon/main.ts | 27 +- src/http/app.ts | 14 +- vendor/README.md | 13 + ...agent-device-proxy-0.21.27-dev-54e4e4d.tgz | Bin 0 -> 29353 bytes 22 files changed, 1125 insertions(+), 19 deletions(-) create mode 100644 src/agent-device/front/allocate.ts create mode 100644 src/agent-device/front/context.ts create mode 100644 src/agent-device/front/front.ts create mode 100644 src/agent-device/front/host-link.ts create mode 100644 src/agent-device/front/release.ts create mode 100644 src/agent-device/shared/paths.ts create mode 100644 src/agent-device/shared/replies.ts create mode 100644 vendor/README.md create mode 100644 vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b024c5e85..697d33741 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -35,7 +35,7 @@ edit yourself keeps the mode you give it until the next `config set`; run | `gateway.url` | **Worker side.** Base URL of the gateway this worker joins, e.g. `wss://gw.example:4700`; the worker dials `/v1/uplink` and upgrades it to a WebSocket. Joining grants that gateway the `admin` role on this daemon, and `gateway.token` rides on the upgrade request as a bearer credential, so use `wss://` — or plain `ws://` only over loopback or inside your own tunnel. `http://` and `https://` are rejected at load. Unset means "do not join a fleet": the default, and the only thing that changes about a joined worker. | unset | | `gateway.token` | **Worker side.** The join token (`simlock token create --role worker`, minted on the gateway) this worker presents when it opens its uplink. Required whenever `gateway.url` is set. `simlock config` shows it; the daemon log and the `daemon.started` event show `[redacted]`. It is stored in plain text in `config.json`, which `simlock config set` writes owner-only. | unset | | `gateway.label` | **Worker side.** Display name for this worker in `simlock worker list`, `status`, the console, and on the lease's `worker` block. Display-only: nothing routes on it and it need not be unique. | the worker's own id | -| `agentDevice.enabled` | **Worker side.** Run one agent-device daemon on this worker, started and watched by Simlock. It sees Simlock's devices and no others, refuses `boot` and `shutdown`, and keeps its files in `~/.simlock/agent-device`. If agent-device is missing, older than 0.21.25, speaks another RPC protocol or needs a newer Node than Simlock runs on, it is not started and Simlock runs on without it. A daemon a killed Simlock left behind is stopped at the next start. | `false` | +| `agentDevice.enabled` | **Worker side.** Run one agent-device daemon on this worker, started and watched by Simlock. It sees Simlock's devices and no others, refuses `boot` and `shutdown`, and keeps its files in `~/.simlock/agent-device`. If agent-device is missing, older than 0.21.25, speaks another RPC protocol or needs a newer Node than Simlock runs on, it is not started and Simlock runs on without it. A daemon a killed Simlock left behind is stopped at the next start. With `http.enabled` also `true`, the agent-device CLI reaches it at `/v1/agent-device` (see [HTTP-API.md](HTTP-API.md#v1agent-device)). | `false` | | `agentDevice.command` | **Worker side.** The agent-device executable: a name looked up on `PATH`, or a path. Used only when `agentDevice.enabled` is `true`. | `agent-device` | | `exec.timeoutMs` | **Worker side.** How long one `device.exec` command (`simlock simctl` / `simlock adb` against a gateway or over HTTP) may run before the worker kills it and the operation fails with `EXEC_TIMEOUT`. Authoritative: it bounds the process that actually runs. | `10 minutes` | | `gateway.routing` | **Gateway side.** Which routing policy places a queued request on a worker. `warm-then-free` is the only policy in v1. Among the workers that can serve the request, in order: a worker whose health is not `running`, or that has requests of its own waiting, is left out; a worker holding a ready device that fits goes first (the device's mode must fit the request, a request with no mode fits a device that serves the worker's default mode, and its runtime must be the one requested or, with none requested, the one the worker would pick: its default runtime if the model pairs with it, otherwise the only runtime the model pairs with); otherwise a worker with no free running slot for the platform or overall is left out (a running device that is not leased counts as free: the worker shuts it down to make room), a worker under its RAM budget for the platform is preferred over one at it (a worker at its budget is still asked when it is the only one left), and the one with the most free running capacity for the platform wins. A worker that is left out is busy, not unable: the request waits for it. Ties go to the lowest worker id. | `warm-then-free` | diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 825b50fbf..e8d26a5a3 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1453,6 +1453,72 @@ from that worker's local agents, each with the **`workerId`** whose queue it is in. A request the gateway has sent to a worker is listed once, as the gateway's own. A worker's `createdAt` comes from that worker's clock. +### `/v1/agent-device/*` + +A **worker** with `agentDevice.enabled` and `http.enabled` both `true` serves +agent-device's proxy URL here. Point the agent-device CLI at it and give it +your Simlock token: + +```bash +export AGENT_DEVICE_DAEMON_AUTH_TOKEN= +agent-device connect proxy --daemon-base-url http://:/v1/agent-device +agent-device open MyApp --platform ios --device "iPhone 17 Pro" +agent-device snapshot +agent-device close +``` + +With `agentDevice.enabled` `false`, and on a gateway, every `/v1/agent-device` +URL answers `404`. + +- **Every route needs the Simlock token.** No token is `401`, a worker join + token is `403`, and so is an `operator` token, with + `refused: agent-device through simlock needs an agent token`. +- **Each agent needs its own Simlock token.** Simlock tells agents apart by + token: two agents sharing one token share one agent-device session. +- **`open` leases a device from Simlock.** The device is picked by Simlock, + and waits in Simlock's queue, up to 330 seconds, when every match is taken. + The lease is an ordinary Simlock lease: `simlock list` shows it, owned by the + token's principal, and the lease ID is the 32-hex ID the CLI chose. `close` + ends the agent-device lease and then frees the Simlock lease, so Simlock + wipes the device for the next agent. +- **Heartbeats** reach agent-device unchanged. They do not renew the Simlock + lease yet, so a Simlock lease ends at its own TTL; pass `ttlMs` on `open` + for a longer one. + +What `open` asks Simlock for: + +| The agent passes | Simlock is asked for | +| --- | --- | +| `--platform ios` or `android` | that platform, any device Simlock grants | +| `--target mobile`, `tv`, `desktop` | the class `phone`, `tv`, `desktop` | +| `--device "iPhone 17 Pro"` | that model, and no class: the model wins over `--target` | +| `ttlMs` | that TTL, at least 5 seconds; none sent means `lease.defaultTtlMs` | + +Messages the route prints. The agent-device CLI reads the message in the body +(a JSON-RPC error on `/rpc`, `{ "ok": false, "error", "code" }` elsewhere), not +the status. + +- `agent-device is not available on this simlock: `: agent-device is + turned on but missing, too old, starting or crashed. The rest of Simlock + works as before. +- `timed out waiting for a device; nothing was leased`. +- `refused by simlock: `: Simlock refused the lease request, for + example a model it does not know, or a `ttlMs` above `lease.maxTtlMs`. +- `refused: only iOS and Android`, `refused: name --platform ios or android`. +- `refused: simlock picks the device`: `--udid` or `--serial` on `open`. +- `refused: this agent-device is too old for simlock; upgrade it`: the CLI + sent no lease ID. +- `refused: send lease calls as lease RPCs`: a lease call inside + `agent_device.command`. +- `refused: ttlMs must be a positive number of milliseconds`. +- `refused: simlock leases only plain devices`: `backend`, + `retainOnClose: true`, a `provider*` or `aws*` key, or a `leaseProvider` + other than `proxy`. +- `refused: agent-device through simlock needs an agent token`. +- `refused: request body over 1 MiB` and `refused: send one JSON-RPC request` + (a body that is not JSON, a JSON array, or an object with no string + `method`); both answer with `id: null`. + ## Errors Every failure is the same shape the daemon protocol uses: diff --git a/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index 03980e904..a8d506f29 100644 --- a/docs/internal/COMPONENTS.md +++ b/docs/internal/COMPONENTS.md @@ -114,6 +114,10 @@ modules with. | `AgentDeviceSupervisor` | `src/agent-device/host/agent-device-supervisor.ts` | Starting, watching and stopping the one agent-device daemon: reaping one a killed simlock left (by a pid record and an identity check), the private state dir and policy file, the daemon's environment, readiness through `daemon.json`, the port and `/health`, restart on death, and the availability state (`off`, `starting`, `running`, `unavailable` with a reason and remedy). | Serve agent-device traffic; report to `simlock doctor`; decide which devices exist (the drivers' environments do). | | Install lookup, compatibility, daemon environment | `src/agent-device/host/install.ts`, `src/agent-device/shared/` | Finding the agent-device install and its daemon entry; the minimum versions and RPC protocol; the environment and policy data. | Run a process. | | `AgentDeviceHealthProbe` | `src/agent-device/host/health.ts` | The `GET /health` read, with a time limit. | Judge the answer. | +| Front | `src/agent-device/front/` | The `/v1/agent-device/*` mount: the role check, the `/rpc` body check, turning an agent-device `allocate` into a simlock `lease.request` (and its cancel when the CLI leaves) and a `release` into a `lease.release`, and the front map of the leases it made. | Check a token (the app does); run the proxy; renew leases (#473). | +| `AgentDeviceHost`, `AgentDeviceUpstream` | `src/agent-device/host/host.ts`, `host/upstream.ts` | `@agent-device/proxy` in front of the daemon, one proxy per daemon start; the tenant each request acts as; the port the daemon is reached through and its supervisor adapter. | Know simlock leases. | +| Wire rules | `src/agent-device/shared/` | The path prefixes, the `/rpc` body reader, the call classifier, the lease-call field reader, the reply reader and the front's replies. | Decide what a call means. | +| `FakeAgentDevice` | `src/agent-device/testing.ts` | The in-memory agent-device daemon the proxy sits in front of in tests. | Appear in production code. | ## Drivers diff --git a/package.json b/package.json index 8c7bc5295..84f9c8000 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "mutate": "node scripts/mutate.mjs" }, "dependencies": { + "@agent-device/proxy": "file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz", "@hono/node-server": "2.0.10", "@hono/zod-validator": "^0.9.1", "@modelcontextprotocol/sdk": "1.29.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de6a5b20..27ca0cc80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: .: dependencies: + '@agent-device/proxy': + specifier: file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz + version: file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz '@hono/node-server': specifier: 2.0.10 version: 2.0.10(hono@4.13.5) @@ -311,6 +314,11 @@ importers: packages: + '@agent-device/proxy@file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz': + resolution: {integrity: sha512-/Q0a2HgHF82DmisG8y5sYsguc5sBc8nEuWuylaemVUWaPmqpJTvwV6aVVsGcluJInU29361hyN27zSbl1v7FiQ==, tarball: file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz} + version: 0.21.27-dev + engines: {node: '>=22.12'} + '@axe-core/playwright@4.13.0': resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} peerDependencies: @@ -3093,6 +3101,8 @@ packages: snapshots: + '@agent-device/proxy@file:vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz': {} + '@axe-core/playwright@4.13.0(playwright-core@1.63.0)': dependencies: axe-core: 4.13.0 diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index d9f9520f3..39f3c519d 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -82,6 +82,11 @@ interface Rig { readonly started: Promise; } +const phoneModels = { + defaultModels: { phone: ["iPhone 16"] }, + modelClasses: { "iPhone 16": "phone" }, +} as const; + function iosDriver(clock: FakeClock): FakeDriver { return new FakeDriver({ availableOsVersions: ["26.5"], @@ -530,7 +535,7 @@ describe("the agent-device front, through the whole worker daemon", () => { }); it('allocate `--target mobile`, `tv` and `desktop` ask simlock for class `phone`, `tv` and `desktop`; allocate `--device "iPhone 17 Pro"`, alone and with `--target tv`, asks for that model and no class', async () => { - const rig = await setUp(); + const rig = await setUp({ maxRunning: 8 }); await granted(rig, 1, { target: "mobile" }); await granted(rig, 2, { target: "tv" }); @@ -769,8 +774,10 @@ describe("the agent-device front, through the whole worker daemon", () => { const slow = await setUp({ drivers: (clock) => [ new FakeDriver({ + ...phoneModels, availableOsVersions: ["26.5"], clock, + knownModels: ["iPhone 16"], latencyMs: { listManaged: 30_000 }, platform: "ios", }), @@ -790,6 +797,7 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(parked.done()).toBe(false); early.abort(); await parked.promise.catch(() => undefined); + await sleep(50); slow.clock.advance(30_000); const ready = { ...slow, daemon: await slow.started }; await sleep(100); @@ -802,6 +810,7 @@ describe("the agent-device front, through the whole worker daemon", () => { it("a CLI that hangs up after device work started for it gets no agent-device lease: the grant that lands later is released at once and never reaches the agent-device daemon; when that device work fails and the request goes back to the queue, the front's repeated cancel removes it, and it is never granted", async () => { const slowDevice = (clock: FakeClock) => [ new FakeDriver({ + ...phoneModels, availableOsVersions: ["26.5"], clock, knownModels: ["iPhone 16"], @@ -880,7 +889,7 @@ describe("the agent-device front, through the whole worker daemon", () => { it("an allocate still waiting 330 s after the front admitted it answers `timed out waiting for a device; nothing was leased`, and simlock's queue is empty", async () => { const rig = await setUp({ maxRunning: 1 }); - await granted(rig, 1, {}, { token: secrets.two }); + await granted(rig, 1, { ttlMs: maxTtlMs }, { token: secrets.two }); const second = track(allocate(rig, 2)); await expect.poll(async () => (await waiting(rig)).length).toBe(1); @@ -915,6 +924,7 @@ describe("the agent-device front, through the whole worker daemon", () => { const bare = await setUp({ drivers: (clock) => [ new NoAddressDriver({ + ...phoneModels, availableOsVersions: ["26.5"], clock, knownModels: ["iPhone 16"], @@ -969,6 +979,7 @@ describe("the agent-device front, through the whole worker daemon", () => { hangUp.abort(); await first.promise.catch(() => undefined); + await sleep(50); rig.fake.letAllocateGo(); await expect.poll(async () => (await leases(rig)).length).toBe(0); @@ -984,6 +995,7 @@ describe("the agent-device front, through the whole worker daemon", () => { await expect.poll(() => allocatesReceived(rig, 2).length).toBe(1); abort.abort(); await second.promise.catch(() => undefined); + await sleep(50); rig.fake.letAllocateGo(); await expect.poll(() => rig.fake.rpcRequests("agent_device.lease.release").length).toBe(2); diff --git a/src/agent-device/front/allocate.ts b/src/agent-device/front/allocate.ts new file mode 100644 index 000000000..29bf866f7 --- /dev/null +++ b/src/agent-device/front/allocate.ts @@ -0,0 +1,322 @@ +import type { z } from "zod"; + +import type { OPERATIONS } from "../../contract/index.js"; +import { buildHttpSession } from "../../http/dispatcher-session.js"; +import { mapError } from "../../http/errors.js"; +import type { TokenIdentity } from "../../http/token-store.js"; +import type { Clock } from "../../ports/index.js"; +import { frontReply, failure, refusal, type FrontReply } from "../shared/replies.js"; +import { leaseCallFields, leaseIdOf, type RpcRead } from "../shared/rpc.js"; +import { exchange, leaseCall, releaseSimlock, relay, type FrontContext } from "./context.js"; + +type Grant = z.infer<(typeof OPERATIONS)["lease.request"]["output"]>; + +const QUEUE_WAIT_MS = 330_000; +const MIN_TTL_MS = 5_000; +const CANCEL_RETRY_MS = 1_000; +const LEASE_GONE = new Set(["LEASE_NOT_FOUND", "LEASE_EXPIRED", "LEASE_REVOKED"]); +const CLASS_OF_TARGET: Record = { + desktop: "desktop", + mobile: "phone", + tv: "tv", +}; +const SCOPE_KEYS = ["runId", "leaseProvider", "clientId", "deviceKey"] as const; + +const present = (value: unknown): boolean => value !== undefined && value !== null; + +interface Plan { + readonly leaseId: string; + readonly platform: "ios" | "android"; + readonly model?: string; + readonly class?: "phone" | "tv" | "desktop"; + readonly ttlMs?: number; +} + +function notPlain(params: Record): boolean { + const provider = params["leaseProvider"]; + return ( + present(params["backend"]) || + params["retainOnClose"] === true || + (present(provider) && provider !== "proxy") || + Object.entries(params).some( + ([key, value]) => present(value) && (key.startsWith("provider") || key.startsWith("aws")), + ) + ); +} + +function planAllocate(params: Record): Plan | FrontReply { + const fields = leaseCallFields(params); + const leaseId = leaseIdOf(fields.leaseId); + if (leaseId === undefined) { + return refusal("refused: this agent-device is too old for simlock; upgrade it"); + } + if (notPlain(params)) return refusal("refused: simlock leases only plain devices"); + if (!present(fields.platform)) return refusal("refused: name --platform ios or android"); + if (fields.platform !== "ios" && fields.platform !== "android") { + return refusal("refused: only iOS and Android"); + } + if (present(fields.udid) || present(fields.serial)) { + return refusal("refused: simlock picks the device"); + } + const ttl = fields.ttlMs; + if (present(ttl) && !(typeof ttl === "number" && Number.isFinite(ttl) && ttl > 0)) { + return refusal("refused: ttlMs must be a positive number of milliseconds"); + } + const device = + typeof fields.device === "string" && fields.device !== "" ? fields.device : undefined; + const klass = typeof fields.target === "string" ? CLASS_OF_TARGET[fields.target] : undefined; + return { + leaseId, + platform: fields.platform, + ...(device === undefined ? (klass === undefined ? {} : { class: klass }) : { model: device }), + ...(typeof ttl === "number" ? { ttlMs: Math.max(ttl, MIN_TTL_MS) } : {}), + }; +} + +function pause(clock: Clock, ms: number, wakeOn: (wake: () => void) => void): Promise { + return new Promise((resolve) => { + const timer = clock.setTimer(ms, resolve); + wakeOn(() => { + clock.cancel(timer); + resolve(); + }); + }); +} + +type LeaseOutcome = + | { readonly kind: "granted"; readonly grant: Grant } + | { readonly kind: "failed"; readonly reply: FrontReply } + | { readonly kind: "gone" }; + +// why: a request parked while the daemon starts cannot be called back, so wait on a read that parks at the same gate and give up if the CLI leaves, or a CLI that left would still be admitted. +async function stillWaiting( + context: FrontContext, + identity: TokenIdentity, + signal: AbortSignal, +): Promise { + if (signal.aborted) return false; + const gone = new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(false), { once: true }); + }); + const ready = context.dispatch("lease.list", {}, buildHttpSession(identity)).then( + () => true, + () => true, + ); + return Promise.race([ready, gone]); +} + +async function requestLease( + context: FrontContext, + identity: TokenIdentity, + plan: Plan, + signal: AbortSignal, +): Promise { + if (!(await stillWaiting(context, identity, signal))) return { kind: "gone" }; + let admitted = false; + let aborted = signal.aborted; + let settled = false; + let cancelling = false; + let wakeCancel: (() => void) | undefined; + const session = buildHttpSession(identity); + const cancelUntilGone = async (): Promise => { + const until = context.clock.now() + QUEUE_WAIT_MS; + while (!settled && context.clock.now() < until) { + try { + const { result } = await context.dispatch( + "lease.cancel", + { requesterId: plan.leaseId }, + session, + ); + if (result !== "not-cancellable") return; + } catch { + return; + } + await pause(context.clock, CANCEL_RETRY_MS, (wake) => { + wakeCancel = wake; + }); + } + }; + const startCancel = (): void => { + if (cancelling) return; + cancelling = true; + void cancelUntilGone(); + }; + const onAbort = (): void => { + aborted = true; + if (admitted) startCancel(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + const grant = await context.dispatch( + "lease.request", + { + leaseId: plan.leaseId, + platform: plan.platform, + requesterId: plan.leaseId, + timeoutMs: QUEUE_WAIT_MS, + ...(plan.model === undefined ? {} : { model: plan.model }), + ...(plan.class === undefined ? {} : { class: plan.class }), + ...(plan.ttlMs === undefined ? {} : { ttlMs: plan.ttlMs }), + }, + buildHttpSession(identity, { + onRequestAdmitted: () => { + admitted = true; + if (aborted) startCancel(); + }, + }), + ); + return { grant, kind: "granted" }; + } catch (error: unknown) { + const mapped = mapError(error); + return { + kind: "failed", + reply: + mapped.code === "QUEUE_TIMEOUT" + ? failure("timed out waiting for a device; nothing was leased", 504) + : refusal(`refused by simlock: ${mapped.message}`), + }; + } finally { + settled = true; + wakeCancel?.(); + signal.removeEventListener("abort", onAbort); + } +} + +function scopeOf(source: Record | undefined): Record { + const scope: Record = {}; + for (const key of SCOPE_KEYS) { + if (source?.[key] !== undefined) scope[key] = source[key]; + } + return scope; +} + +async function releaseAgentDevice( + context: FrontContext, + request: Request, + identity: TokenIdentity, + leaseId: string, + scope: Record, +) { + return exchange( + context, + leaseCall(request, "agent_device.lease.release", { ...scope, leaseId }, "simlock-release"), + identity, + ); +} + +function forwardedBody( + call: RpcRead & { kind: "request" }, + plan: Plan, + address: string, + ceiling: number, +) { + const { device: _device, deviceKey: _deviceKey, target: _target, ...rest } = call.params; + return { + ...call.body, + params: { + ...rest, + ttlMs: ceiling, + ...(plan.platform === "ios" ? { udid: address } : { serial: address }), + }, + }; +} + +export interface AllocateArguments { + readonly request: Request; + readonly identity: TokenIdentity; + readonly call: RpcRead & { readonly kind: "request" }; + readonly leaseMaxTtlMs: number; +} + +export async function allocate( + context: FrontContext, + { request, identity, call, leaseMaxTtlMs }: AllocateArguments, +): Promise { + const planned = planAllocate(call.params); + if ("message" in planned) return frontReply(planned, call); + const plan = planned; + await context.map.refresh((who) => listLeases(context, who)); + if (request.signal.aborted) return undefined; + const outcome = await requestLease(context, identity, plan, request.signal); + if (outcome.kind === "gone") return undefined; + if (outcome.kind === "failed") return frontReply(outcome.reply, call); + const { grant } = outcome; + if (request.signal.aborted) { + await releaseSimlock(context, identity, plan.leaseId); + return undefined; + } + const address = grant.device.address; + if (address === undefined || address === "") { + await releaseSimlock(context, identity, plan.leaseId); + return frontReply( + failure("simlock granted a device with no address; nothing was leased"), + call, + ); + } + const forwarded = new Request(request.url, { + body: JSON.stringify(forwardedBody(call, plan, address, leaseMaxTtlMs)), + headers: leaseCall(request, "agent_device.lease.allocate", {}, null).headers, + method: "POST", + }); + const reply = await exchange(context, forwarded, identity); + const clientGone = request.signal.aborted; + if (reply.reading.kind === "success") { + if (!clientGone) { + context.map.add(plan.leaseId, { + address, + deadline: grant.lease.ttlDeadline, + identity, + ttlMs: grant.lease.ttlMs, + }); + return relay(reply); + } + const lease = reply.reading.result["lease"]; + const named = + typeof lease === "object" && lease !== null ? (lease as Record) : {}; + const ended = await releaseAgentDevice( + context, + request, + identity, + plan.leaseId, + scopeOf(named), + ); + if (ended.reading.kind === "success") await releaseSimlock(context, identity, plan.leaseId); + return undefined; + } + if (reply.reading.kind === "error") { + await releaseSimlock(context, identity, plan.leaseId); + return relay(reply); + } + const ended = await releaseAgentDevice( + context, + request, + identity, + plan.leaseId, + scopeOf(call.params), + ); + const holds = + ended.reading.kind === "error" && + (ended.reading.code === undefined || !LEASE_GONE.has(ended.reading.code)); + if (!holds) await releaseSimlock(context, identity, plan.leaseId); + return frontReply(failure("agent-device did not answer the allocate"), call); +} + +const LIST_TIMEOUT_MS = 5_000; + +async function listLeases(context: FrontContext, identity: TokenIdentity) { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = context.clock.setTimer(LIST_TIMEOUT_MS, () => + reject(new Error("lease.list timed out")), + ); + }); + try { + const { leases } = await Promise.race([ + context.dispatch("lease.list", {}, buildHttpSession(identity)), + timeout, + ]); + return leases.map((lease) => ({ id: lease.id, ttlDeadline: lease.ttlDeadline })); + } finally { + if (timer !== undefined) context.clock.cancel(timer); + } +} diff --git a/src/agent-device/front/context.ts b/src/agent-device/front/context.ts new file mode 100644 index 000000000..b70cf3a32 --- /dev/null +++ b/src/agent-device/front/context.ts @@ -0,0 +1,83 @@ +import type { HttpDispatch } from "../../http/dispatcher-session.js"; +import type { TokenIdentity } from "../../http/token-store.js"; +import type { Clock, Logger } from "../../ports/index.js"; +import { buildHttpSession } from "../../http/dispatcher-session.js"; +import { mapError } from "../../http/errors.js"; +import { readReply, type ReplyReading } from "../shared/rpc.js"; +import type { FrontMap } from "./front-map.js"; +import type { HostLink } from "./host-link.js"; + +export interface FrontContext { + readonly link: HostLink; + readonly map: FrontMap; + readonly dispatch: HttpDispatch; + readonly clock: Clock; + readonly logger: Logger; +} + +export interface Exchange { + readonly response: Response | undefined; + readonly text: string | undefined; + readonly reading: ReplyReading; +} + +export async function exchange( + context: FrontContext, + request: Request, + identity: TokenIdentity, +): Promise { + try { + const response = await context.link.forward(request, identity); + const text = await response.text(); + return { reading: readReply(text), response, text }; + } catch (error) { + context.logger.warn("agent-device lease call got no usable reply", { + message: error instanceof Error ? error.message : String(error), + }); + return { reading: { kind: "unusable" }, response: undefined, text: undefined }; + } +} + +export function relay(reply: { + response: Response | undefined; + text: string | undefined; +}): Response { + const headers = new Headers(reply.response?.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + return new Response(reply.text ?? "", { headers, status: reply.response?.status ?? 502 }); +} + +export function leaseCall( + origin: Request, + method: "agent_device.lease.allocate" | "agent_device.lease.release", + params: Record, + id: unknown, +): Request { + const headers = new Headers(origin.headers); + headers.delete("content-length"); + headers.set("content-type", "application/json"); + return new Request(origin.url, { + body: JSON.stringify({ id, jsonrpc: "2.0", method, params }), + headers, + method: "POST", + }); +} + +export async function releaseSimlock( + context: FrontContext, + identity: TokenIdentity, + leaseId: string, +): Promise { + try { + await context.dispatch("lease.release", { leaseId }, buildHttpSession(identity)); + return true; + } catch (error) { + if (mapError(error).code === "UNKNOWN_LEASE") return true; + context.logger.warn("agent-device front could not release a simlock lease", { + leaseId, + message: error instanceof Error ? error.message : String(error), + }); + return false; + } +} diff --git a/src/agent-device/front/front-map.ts b/src/agent-device/front/front-map.ts index f82aae657..2a5a7129a 100644 --- a/src/agent-device/front/front-map.ts +++ b/src/agent-device/front/front-map.ts @@ -1,6 +1,8 @@ import type { TokenIdentity } from "../../http/token-store.js"; import type { Clock } from "../../ports/index.js"; +const MAX_PRINCIPALS_PER_REFRESH = 100; + export interface FrontMapEntry { readonly identity: TokenIdentity; readonly address: string; @@ -13,18 +15,81 @@ export interface ListedLease { readonly ttlDeadline: number; } +interface Held { + readonly identity: TokenIdentity; + readonly address: string; + readonly ttlMs: number; + deadline: number; + endedAt?: number; +} + export class FrontMap { + readonly #held = new Map(); + constructor(readonly clock: Clock) {} - add(_leaseId: string, _entry: FrontMapEntry): void {} + add(leaseId: string, entry: FrontMapEntry): void { + this.#prune(); + this.#held.set(leaseId, { ...entry }); + } + + get(leaseId: string): (FrontMapEntry & { readonly endedAt?: number }) | undefined { + this.#prune(); + return this.#held.get(leaseId); + } - get(_leaseId: string): (FrontMapEntry & { readonly endedAt?: number }) | undefined { - return undefined; + markEnded(leaseId: string): void { + const held = this.#held.get(leaseId); + if (held !== undefined) held.endedAt ??= this.clock.now(); } - markEnded(_leaseId: string): void {} + async refresh(list: (identity: TokenIdentity) => Promise): Promise { + this.#prune(); + const now = this.clock.now(); + const byPrincipal = new Map< + string, + { identity: TokenIdentity; oldest: number; ids: string[] } + >(); + for (const [id, held] of this.#held) { + if (held.endedAt !== undefined || held.deadline > now) continue; + const group = byPrincipal.get(held.identity.requesterId); + if (group === undefined) { + byPrincipal.set(held.identity.requesterId, { + identity: held.identity, + ids: [id], + oldest: held.deadline, + }); + } else { + group.ids.push(id); + group.oldest = Math.min(group.oldest, held.deadline); + } + } + const groups = [...byPrincipal.values()] + .sort((a, b) => a.oldest - b.oldest) + .slice(0, MAX_PRINCIPALS_PER_REFRESH); + await Promise.all( + groups.map(async (group) => { + let listed: readonly ListedLease[]; + try { + listed = await list(group.identity); + } catch { + return; + } + for (const id of group.ids) { + const held = this.#held.get(id); + if (held === undefined) continue; + const found = listed.find((lease) => lease.id === id); + if (found === undefined) held.endedAt ??= this.clock.now(); + else held.deadline = found.ttlDeadline; + } + }), + ); + } - async refresh( - _list: (identity: TokenIdentity) => Promise, - ): Promise {} + #prune(): void { + const now = this.clock.now(); + for (const [id, held] of this.#held) { + if (held.endedAt !== undefined && now - held.endedAt >= held.ttlMs) this.#held.delete(id); + } + } } diff --git a/src/agent-device/front/front.ts b/src/agent-device/front/front.ts new file mode 100644 index 000000000..095709d52 --- /dev/null +++ b/src/agent-device/front/front.ts @@ -0,0 +1,101 @@ +import type { HttpMount } from "../../http/app.js"; +import type { TokenIdentity } from "../../http/token-store.js"; +import { FRONT_MOUNT, FRONT_PREFIX } from "../shared/paths.js"; +import { failure, frontReply, notAvailable, refusal } from "../shared/replies.js"; +import { classifyCall, readRpcBody, type RpcRead } from "../shared/rpc.js"; +import { allocate } from "./allocate.js"; +import type { FrontContext } from "./context.js"; +import { release } from "./release.js"; + +type RpcRequest = RpcRead & { readonly kind: "request" }; + +const AGENT_TOKEN_ONLY = "refused: agent-device through simlock needs an agent token"; +const CLIENT_GONE = 499; + +function carry(request: Request, body: string): Request { + const headers = new Headers(request.headers); + headers.delete("content-length"); + return new Request(request.url, { + body, + headers, + method: request.method, + signal: request.signal, + }); +} + +// why: the allocate keeps cleaning up (cancel, release) after the CLI hung up, while the handler answers at once. +function untilHangUp(signal: AbortSignal, work: Promise): Promise { + const gone = new Promise((resolve) => { + const answer = () => resolve(new Response(null, { status: CLIENT_GONE })); + if (signal.aborted) answer(); + else signal.addEventListener("abort", answer, { once: true }); + }); + return Promise.race([work.then((response) => response ?? gone), gone]); +} + +async function handleRpc( + context: FrontContext, + request: Request, + identity: TokenIdentity, +): Promise { + const read = await readRpcBody(request); + if (read.kind === "too-large") { + return frontReply(refusal("refused: request body over 1 MiB"), { id: null }); + } + if (read.kind === "malformed") { + return frontReply(refusal("refused: send one JSON-RPC request"), { id: null }); + } + const status = context.link.status(); + if (status.state === "unavailable") return frontReply(notAvailable(status.reason), read); + const kind = classifyCall(read.method, read.params); + if (kind === "wrapped-lease") { + return frontReply(refusal("refused: send lease calls as lease RPCs"), read); + } + if (kind === "allocate") { + const work = allocate(context, { + call: read, + identity, + leaseMaxTtlMs: status.leaseMaxTtlMs, + request, + }).catch((error: unknown) => { + context.logger.error("agent-device allocate failed", { + message: error instanceof Error ? error.message : String(error), + }); + return frontReply(failure("agent-device allocate failed"), read); + }); + return untilHangUp(request.signal, work); + } + return kind === "release" + ? release(context, identity, read as RpcRequest, carry(request, read.text)) + : passThrough(context, carry(request, read.text), identity, read); +} + +async function passThrough( + context: FrontContext, + request: Request, + identity: TokenIdentity, + rpc: { readonly id: unknown } | undefined, +): Promise { + try { + return await context.link.forward(request, identity); + } catch (error) { + return frontReply(notAvailable(error instanceof Error ? error.message : String(error)), rpc); + } +} + +export function createFront(context: FrontContext): HttpMount { + return { + handle: async (request, identity) => { + const isRpc = + request.method === "POST" && new URL(request.url).pathname === `${FRONT_PREFIX}/rpc`; + if (identity.role !== "agent") { + return frontReply(refusal(AGENT_TOKEN_ONLY, 403), isRpc ? { id: null } : undefined); + } + if (isRpc) return handleRpc(context, request, identity); + const status = context.link.status(); + if (status.state === "unavailable") return frontReply(notAvailable(status.reason), undefined); + return passThrough(context, request, identity, undefined); + }, + prefix: FRONT_MOUNT, + }; +} diff --git a/src/agent-device/front/host-link.ts b/src/agent-device/front/host-link.ts new file mode 100644 index 000000000..fddf36abe --- /dev/null +++ b/src/agent-device/front/host-link.ts @@ -0,0 +1,23 @@ +import type { TokenIdentity } from "../../http/token-store.js"; +import type { AgentDeviceHost } from "../host/host.js"; + +export interface HostLink { + status(): + | { readonly state: "running"; readonly leaseMaxTtlMs: number } + | { readonly state: "unavailable"; readonly reason: string }; + forward(request: Request, identity: TokenIdentity): Promise; +} + +export class InProcessHostLink implements HostLink { + constructor(private readonly host: AgentDeviceHost) {} + + // fallow-ignore-next-line unused-class-member -- reached through the HostLink port by the front. + status(): ReturnType { + return this.host.status(); + } + + // fallow-ignore-next-line unused-class-member -- reached through the HostLink port by the front. + forward(request: Request, identity: TokenIdentity): Promise { + return this.host.forward(request, identity); + } +} diff --git a/src/agent-device/front/release.ts b/src/agent-device/front/release.ts new file mode 100644 index 000000000..793e75277 --- /dev/null +++ b/src/agent-device/front/release.ts @@ -0,0 +1,28 @@ +import type { TokenIdentity } from "../../http/token-store.js"; +import { failure, frontReply } from "../shared/replies.js"; +import { leaseCallFields, leaseIdOf, type RpcRead } from "../shared/rpc.js"; +import { exchange, relay, releaseSimlock, type FrontContext } from "./context.js"; + +export async function release( + context: FrontContext, + identity: TokenIdentity, + call: RpcRead & { readonly kind: "request" }, + forwarded: Request, +): Promise { + const reply = await exchange(context, forwarded, identity); + if (reply.response === undefined) { + return frontReply(failure("agent-device did not answer the release"), call); + } + const leaseId = leaseIdOf(leaseCallFields(call.params).leaseId); + const entry = leaseId === undefined ? undefined : context.map.get(leaseId); + if ( + leaseId !== undefined && + entry?.identity.requesterId === identity.requesterId && + entry.endedAt === undefined && + reply.reading.kind === "success" && + (await releaseSimlock(context, entry.identity, leaseId)) + ) { + context.map.markEnded(leaseId); + } + return relay(reply); +} diff --git a/src/agent-device/host/host.ts b/src/agent-device/host/host.ts index 0ccbcabed..de605ed2c 100644 --- a/src/agent-device/host/host.ts +++ b/src/agent-device/host/host.ts @@ -1,12 +1,130 @@ +import { randomUUID } from "node:crypto"; + import type { TokenIdentity } from "../../http/token-store.js"; +import { FRONT_PREFIX, PROXY_BASE_PATH } from "../shared/paths.js"; import type { AgentDeviceUpstream } from "./upstream.js"; +type ProxyModule = typeof import("@agent-device/proxy"); +type DaemonProxy = ReturnType; + +// why: the proxy hands `authenticate` a copy of the request, so the tag travels as a header the front always overwrites. +const TAG_HEADER = "x-simlock-front-tag"; +const PREFLIGHT_PATH = `${PROXY_BASE_PATH}/upload/preflight`; + +export type HostStatus = + | { readonly state: "running"; readonly leaseMaxTtlMs: number } + | { readonly state: "unavailable"; readonly reason: string }; + +function stripBodyHeaders(headers: Headers): Headers { + const copy = new Headers(headers); + copy.delete("content-length"); + copy.delete("content-encoding"); + return copy; +} + export class AgentDeviceHost { + readonly #identities = new Map(); + #loading: Promise | undefined; + #loadError: string | undefined; + #proxy: { readonly generation: number; readonly proxy: DaemonProxy } | undefined; + constructor(readonly upstream: AgentDeviceUpstream) {} - tag(_request: Request, _identity: TokenIdentity): void {} + status(): HostStatus { + if (this.#loadError !== undefined) return { reason: this.#loadError, state: "unavailable" }; + const current = this.upstream.current(); + if (current.state === "unavailable") return current; + void this.#load(); + return { leaseMaxTtlMs: current.leaseMaxTtlMs, state: "running" }; + } + + tag(request: Request, identity: TokenIdentity): string { + const tag = randomUUID(); + this.#identities.set(tag, identity); + request.headers.set(TAG_HEADER, tag); + return tag; + } + + authenticate(request: Request): { readonly tenantId: string } | null { + const tag = request.headers.get(TAG_HEADER); + const identity = tag === null ? undefined : this.#identities.get(tag); + return identity === undefined ? null : { tenantId: identity.requesterId }; + } + + async forward(request: Request, identity: TokenIdentity): Promise { + const status = this.status(); + if (status.state === "unavailable") throw new Error(status.reason); + const module = await this.#load(); + const current = this.upstream.current(); + if (module === undefined || current.state === "unavailable") { + throw new Error(this.#loadError ?? "unavailable"); + } + if (this.#proxy?.generation !== current.generation) { + this.#proxy = { + generation: current.generation, + proxy: module.createDaemonProxy({ + authenticate: (tagged) => this.authenticate(tagged), + upstreamBaseUrl: current.baseUrl, + upstreamFetch: (upstream) => this.upstream.fetch(upstream), + upstreamToken: current.token, + }), + }; + } + const proxied = this.#rewrite(request); + const tag = this.tag(proxied, identity); + try { + const response = await this.#proxy.proxy.handle(proxied); + return new URL(proxied.url).pathname === PREFLIGHT_PATH + ? await this.#withTicketPrefix(response) + : response; + } finally { + this.#identities.delete(tag); + } + } + + #load(): Promise { + this.#loading ??= import("@agent-device/proxy").then( + (module) => module, + (error: unknown) => { + const reason = error instanceof Error ? error.message : String(error); + this.#loadError = `could not load @agent-device/proxy: ${reason}`; + return undefined; + }, + ); + return this.#loading; + } + + #rewrite(request: Request): Request { + const url = new URL(request.url); + if (url.pathname === FRONT_PREFIX || url.pathname.startsWith(`${FRONT_PREFIX}/`)) { + url.pathname = PROXY_BASE_PATH + url.pathname.slice(FRONT_PREFIX.length); + } + return new Request(url, { + headers: request.headers, + method: request.method, + signal: request.signal, + ...(request.body === null ? {} : { body: request.body, duplex: "half" }), + } as RequestInit); + } - authenticate(_request: Request): { readonly tenantId: string } | null { - return null; + async #withTicketPrefix(response: Response): Promise { + // why: the proxy builds the ticket URL from the path it saw, and the client reached it under `/v1`. + const text = await response.text(); + let body = text; + try { + const parsed = JSON.parse(text) as { upload?: { url?: unknown } }; + if (typeof parsed.upload?.url === "string") { + const ticket = new URL(parsed.upload.url); + ticket.pathname = `/v1${ticket.pathname}`; + parsed.upload.url = ticket.toString(); + body = JSON.stringify(parsed); + } + } catch { + body = text; + } + return new Response(body, { + headers: stripBodyHeaders(response.headers), + status: response.status, + }); } } diff --git a/src/agent-device/host/upstream.ts b/src/agent-device/host/upstream.ts index c485a5556..f63cacd6d 100644 --- a/src/agent-device/host/upstream.ts +++ b/src/agent-device/host/upstream.ts @@ -20,7 +20,21 @@ export class SupervisorAgentDeviceUpstream implements AgentDeviceUpstream { // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. current(): AgentDeviceUpstreamState { - return { reason: "not implemented", state: "unavailable" }; + const availability = this.supervisor.availability(); + switch (availability.state) { + case "running": + return { + baseUrl: availability.baseUrl, + generation: availability.generation, + leaseMaxTtlMs: availability.leaseMaxTtlMs, + state: "running", + token: availability.token, + }; + case "unavailable": + return { reason: availability.message, state: "unavailable" }; + default: + return { reason: availability.state === "off" ? "off" : "starting", state: "unavailable" }; + } } // fallow-ignore-next-line unused-class-member -- reached through the AgentDeviceUpstream port by the host. diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index e5c10a8f4..506cdb770 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -1,6 +1,35 @@ +import type { HttpDispatch } from "../http/dispatcher-session.js"; +import type { HttpMount } from "../http/app.js"; +import type { Clock, Logger } from "../ports/index.js"; +import { FrontMap } from "./front/front-map.js"; +import { createFront } from "./front/front.js"; +import { InProcessHostLink, type HostLink } from "./front/host-link.js"; +import { AgentDeviceHost } from "./host/host.js"; +import type { AgentDeviceUpstream } from "./host/upstream.js"; + export { agentDeviceFindings } from "./host/availability-findings.js"; export { AgentDeviceSupervisor } from "./host/agent-device-supervisor.js"; // fallow-ignore-next-line unused-type -- the availability contract the front (#472) and doctor (#471) read. export type { AgentDeviceAvailability } from "./host/agent-device-supervisor.js"; export type { AgentDeviceUpstream } from "./host/upstream.js"; +export { SupervisorAgentDeviceUpstream } from "./host/upstream.js"; export { type AgentDeviceHealthProbe, NodeAgentDeviceHealthProbe } from "./host/health.js"; + +export interface AgentDeviceEdgeParts { + readonly link: HostLink; + readonly map: FrontMap; +} + +export function createAgentDeviceEdgeParts( + upstream: AgentDeviceUpstream, + clock: Clock, +): AgentDeviceEdgeParts { + return { link: new InProcessHostLink(new AgentDeviceHost(upstream)), map: new FrontMap(clock) }; +} + +export function createAgentDeviceFront( + parts: AgentDeviceEdgeParts, + deps: { readonly dispatch: HttpDispatch; readonly clock: Clock; readonly logger: Logger }, +): HttpMount { + return createFront({ ...deps, link: parts.link, map: parts.map }); +} diff --git a/src/agent-device/shared/paths.ts b/src/agent-device/shared/paths.ts new file mode 100644 index 000000000..18a61fc15 --- /dev/null +++ b/src/agent-device/shared/paths.ts @@ -0,0 +1,5 @@ +export const FRONT_MOUNT = "agent-device"; + +export const FRONT_PREFIX = `/v1/${FRONT_MOUNT}`; + +export const PROXY_BASE_PATH = "/agent-device"; diff --git a/src/agent-device/shared/replies.ts b/src/agent-device/shared/replies.ts new file mode 100644 index 000000000..241b7d9fc --- /dev/null +++ b/src/agent-device/shared/replies.ts @@ -0,0 +1,44 @@ +export type ReplyCode = "INVALID_ARGS" | "COMMAND_FAILED"; + +export interface FrontReply { + readonly message: string; + readonly code: ReplyCode; + readonly status: number; +} + +export function notAvailable(reason: string): FrontReply { + return { + code: "COMMAND_FAILED", + message: `agent-device is not available on this simlock: ${reason}`, + status: 503, + }; +} + +export function refusal(message: string, status = 400): FrontReply { + return { code: "INVALID_ARGS", message, status }; +} + +export function failure(message: string, status = 502): FrontReply { + return { code: "COMMAND_FAILED", message, status }; +} + +export function frontReply(reply: FrontReply, rpc: { readonly id: unknown } | undefined): Response { + if (rpc === undefined) { + return Response.json( + { code: reply.code, error: reply.message, ok: false }, + { status: reply.status }, + ); + } + return Response.json( + { + error: { + code: -32000, + data: { code: reply.code, message: reply.message }, + message: reply.message, + }, + id: rpc.id, + jsonrpc: "2.0", + }, + { status: reply.status }, + ); +} diff --git a/src/agent-device/shared/rpc.ts b/src/agent-device/shared/rpc.ts index fc7cfd7a8..603584251 100644 --- a/src/agent-device/shared/rpc.ts +++ b/src/agent-device/shared/rpc.ts @@ -1,3 +1,136 @@ -export function commandLeaseId(_params: unknown): string | undefined { - return undefined; +const MAX_RPC_BODY_BYTES = 1024 * 1024; +const LEASE_ID = /^[0-9a-f]{32}$/; + +export function leaseIdOf(value: unknown): string | undefined { + return typeof value === "string" && LEASE_ID.test(value) ? value : undefined; +} + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export type RpcRead = + | { + readonly kind: "request"; + readonly text: string; + readonly body: Record; + readonly id: unknown; + readonly method: string; + readonly params: Record; + } + | { readonly kind: "too-large" } + | { readonly kind: "malformed" }; + +async function readBounded(body: ReadableStream | null): Promise { + if (body === null) return ""; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RPC_BODY_BYTES) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export async function readRpcBody(request: Request): Promise { + const text = await readBounded(request.body); + if (text === undefined) return { kind: "too-large" }; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { kind: "malformed" }; + } + const body = record(parsed); + if (body === undefined || typeof body["method"] !== "string") return { kind: "malformed" }; + return { + body, + id: body["id"] ?? null, + kind: "request", + method: body["method"], + params: record(body["params"]) ?? {}, + text, + }; +} + +export type CallClass = "allocate" | "heartbeat" | "release" | "wrapped-lease" | "command"; + +const LEASE_METHOD = /^agent[_-]device\.lease\.(allocate|heartbeat|release)$/; +const COMMAND_METHOD = /^agent[_-]device\.command$/; +const WRAPPED_LEASE = /^lease_(allocate|heartbeat|release)$/; + +export function classifyCall(method: string, params: Record): CallClass { + const lease = LEASE_METHOD.exec(method)?.[1]; + if (lease !== undefined) return lease as "allocate" | "heartbeat" | "release"; + if (COMMAND_METHOD.test(method)) { + const command = params["command"]; + if (typeof command === "string" && WRAPPED_LEASE.test(command)) return "wrapped-lease"; + } + return "command"; +} + +export interface LeaseCallFields { + readonly leaseId: unknown; + readonly ttlMs: unknown; + readonly platform: unknown; + readonly target: unknown; + readonly device: unknown; + readonly udid: unknown; + readonly serial: unknown; + readonly deviceKey: unknown; +} + +export function leaseCallFields(params: Record): LeaseCallFields { + return { + device: params["device"], + deviceKey: params["deviceKey"], + leaseId: params["leaseId"], + platform: params["platform"], + serial: params["serial"], + target: params["target"], + ttlMs: params["ttlMs"], + udid: params["udid"], + }; +} + +export function commandLeaseId(params: unknown): string | undefined { + const fields = record(params); + const meta = record(fields?.["meta"]); + const flags = record(fields?.["flags"]); + return leaseIdOf(meta?.["leaseId"] ?? flags?.["leaseId"]); +} + +export type ReplyReading = + | { readonly kind: "success"; readonly result: Record } + | { readonly kind: "error"; readonly code: string | undefined } + | { readonly kind: "unusable" }; + +export function readReply(text: string): ReplyReading { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { kind: "unusable" }; + } + const body = record(parsed); + if (body === undefined || body["jsonrpc"] !== "2.0") return { kind: "unusable" }; + const error = record(body["error"]); + if (error !== undefined) { + const code = record(error["data"])?.["code"]; + return { code: typeof code === "string" ? code : undefined, kind: "error" }; + } + const result = record(body["result"]); + if (result !== undefined && result["ok"] === true && body["error"] === undefined) { + return { kind: "success", result }; + } + return { kind: "unusable" }; } diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 509ff74aa..7d1ecdc04 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -5,6 +5,9 @@ import { pathToFileURL } from "node:url"; import { agentDeviceFindings, AgentDeviceSupervisor, + createAgentDeviceEdgeParts, + createAgentDeviceFront, + SupervisorAgentDeviceUpstream, type AgentDeviceHealthProbe, type AgentDeviceUpstream, NodeAgentDeviceHealthProbe, @@ -52,6 +55,7 @@ import { iosPrerequisites, } from "../drivers/ios/index.js"; import { createHttpApp } from "../http/app.js"; +import type { HttpDispatch } from "../http/dispatcher-session.js"; import { HttpGateway } from "../http/server.js"; import { TokenStore } from "../http/token-store.js"; import { @@ -428,6 +432,13 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise core.closeWarmPool(), @@ -527,7 +538,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { const httpLogger = logger.child("http"); + const dispatch: HttpDispatch = (operation, input, session) => + daemon.dispatch(operation, input, session); const app = createHttpApp({ clock, config, - dispatch: (operation, input, session) => daemon.dispatch(operation, input, session), + dispatch, eventBus, idGenerator, leaseRequests: leasing.requests, logger: httpLogger, + mounts: + agentDeviceParts === undefined + ? [] + : [ + createAgentDeviceFront(agentDeviceParts, { + clock, + dispatch, + logger: logger.child("agent-device"), + }), + ], ownerRoutedFacts: daemon.ownerRoutedFacts, registry, tokens, diff --git a/src/http/app.ts b/src/http/app.ts index b9c45700a..1edd9787c 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -1,5 +1,5 @@ import { zValidator } from "@hono/zod-validator"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { z } from "zod"; import type { EventBus } from "../bus/index.js"; @@ -80,6 +80,12 @@ export interface HttpGatewayDeps { /** `false` on a gateway, which never downloads: `allowDownload` then does not answer the lease * request `POST` early. Default `true`. */ readonly answerDownloadsEarly?: boolean | undefined; + readonly mounts?: readonly HttpMount[]; +} + +export interface HttpMount { + readonly prefix: string; + handle(request: Request, identity: TokenIdentity): Promise; } type Env = AuthEnv; @@ -713,6 +719,12 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa }); }); + for (const mount of deps.mounts ?? []) { + const handler = (c: Context) => mount.handle(c.req.raw, c.get("identity")); + app.all(`/v1/${mount.prefix}`, agentAuth, handler); + app.all(`/v1/${mount.prefix}/*`, agentAuth, handler); + } + // `notices` holds a subscription for the app's lifetime -- exposed here rather than left to // leak, so a caller composing this app into a longer-lived process (the daemon) can // unsubscribe it on shutdown. diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..5ca546bd6 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,13 @@ +# vendor + +`agent-device-proxy-0.21.27-dev-54e4e4d.tgz` is `@agent-device/proxy`, packed +from the `simlock-integration` branch of agent-device at commit +`54e4e4db971d75ba1ebeb75c0ab7bce45c7d9b28`: + +```bash +pnpm --filter @agent-device/proxy pack --pack-destination +``` + +Its `prepack` runs the package's own build, which bundles agent-device's +internal packages into `dist`, so the tarball has no `workspace:` dependency. +It is swapped for the npm release later. diff --git a/vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz b/vendor/agent-device-proxy-0.21.27-dev-54e4e4d.tgz new file mode 100644 index 0000000000000000000000000000000000000000..a7b1c22174bf4ba2785f43813ebb8d969842fdb8 GIT binary patch literal 29353 zcmV)7K*zryiwFP!00000|Lnc{cH2hMAU^+!PtoD{#H2!klH+9Z(WB_2CCcVTw_hAm?vw1Ps=fjsC3`a2-QO>nbH*aNySn;TU0v6?Kfdv= zdF!yZ*F7F||Mc(pZ)-*QQOTTYzZN1w1?$w{RzWwg|t?ljazu$Td-~Y1x`t_f- z{`5Qiw-A}1{%PxX>FfW(|BiY?b{LL%EO@1|m&})Gcs^k-1j0X)iNjYc9gpH?udd(XwOPC)o*_@{$iCJ=$`C%N! z*UV?*WWKB<6P8Uw!9+62?);Q9KMt5LL^2Njj0Y@8#)}z`Ge3j=CSk+{t7TKpssq`g zTE|WT?njj{W>BVz*j<=SlSRf-F0wQn1B?a><8ibI00&hZg|krh0L^jeqLL+SA$SAv zY_M4pgcJCe4G?}?02}NbHNaIjUIkpKBCKBxN?|huW7pOi-qA0n8p^cL`2w_B`U8xMG zocUMDEyuCY%En0+j%m7a0p?~^Ws#WrQN*seoG>1+FlN3roD{kjnIC7NAF+9oVt@Hz zdX>uXeU}YR4u&5({VwYb*lGXd=iYvIpH({p)*DnC>_cz({^V@PK6Lv1&hhXUc5=Wv z$G@*8SUQzdIPPlYXUlbb8q9?l)NPc<=CRzjyqOy*(SU>c%n-F-GZ zVbGxj)$0xb*ipB?_r7yH?7ZzA_J+SSDhIvcF~B@H>9Ynu{pv4&-2cDBfA}ktQqjzaLiltH2JiwHlV^S zPvM4uI$PeWZSU1zpc0j3%Q-flCBY)%^ey0GoQ89_7ocdE`mum}q8al=miiI%7ul4@ zSvdAHxV5k=xMn745@&Fe_%XAQVS&$QN$e4LdD#N!qu!9d0mLMQPnu<+f@w12bN?D^ zPqS<;+O3ui+6(w?i>ju4o`^6@(q*;HU}mi0U56P~VywDIBl+bU1@Jn|ri&|YoXlEd z)xFz`)jkMQK9;>y<-rqjx+{!5A}&0>4r4A1B5@KRB0uiDdgX1uvTA;UskLf)`cH{J zlvTYJuq>@G4)`Z8@Mf8i_%K!tX8@v5YxZ{m-N=prvO_1R{@nhekwT8G+Ggi!$RdNn z3%NaR_xYU10guO_Jw|r}j*~b`{V@)$y5((oTWSW~decN?&6_ZLqS_5lV;-4G6@@G9 zRSS))MHmH8F%v;@7qez_oWzsxn$d5Met8+r&ST~APyg@J|9-c#f7JD6!M~x8_4I%H z)wf&4_uuR7@BX0wzr%ljW+gC$7}*%dq% zG9cv3WRWsBJJ){3@BAgaRIbBY&Qd;0GG1}ujo3H}d7KI6kH=i#Q^=PjpCvK#<0PB% zl+FC{G>mzJCAU0fchhh@@cE$z(XLdQ_Lakh?hu}W%x770!(*#; z170~;rUx>f^6?G5-D2z&=&s-b9z7K%v3)79E`obgX|hR@-uY=DSQ1A|dSoUG=--zU zArWCQ=YeRmQESTmD4UKNY}88U<57bx=27AYqQU$$3nxGh5s3<8DgVcUi;M-Ke;p?x z3&#TbO!;I1c!jaZ;3X%LMLI@2{fxm?&wPAP!^{Fka-M;TQuDhs1Qe-bmWAURp2169 zPns&3rpe-Zia>Cl8}NiwZ&C@CF1u*yRVsb{k42br!H$yv?hA$&8O(I#XFT;Pl|TQP z^$6=qWi%RH`C?j$=QBN&vb{PQjVhI&IG?jH6HIa{Y#b$m=TMPbIm|q^Cr3ha#eBKh z@21d7Ru^;b-w5Vcf^ah7DXdcebPsA~$vBBrR}FT>CkaY0mTa8NX0SujMJ#AM1D@V; zGexv@-w%hUIKoU+=T)kt=tCW;beL|zV$lq zy2r!I{qE1bz3%0Hr+ajAd^tS%seAktG^9;Z((GMl*!|G?<#O+^2kljh$NHNJVedtu z$aq})Ve5yj`WsqUwNT?EjzQpoXVN7`s)**d*`(!bUipGI7iq*ak4BWpo_|9X#|2;Sm!YxA@2!=Lm>R!e+@L&a9;du}RGMr%)i1I|2@_4cLre&zqkD zfWibcK=4!<6_yYV4j0)WP7hCOg$lIMkc90<9A7VL!?TcKbX%0o{guhbJ}BLKdu0!Vqw;P`UE}i?9ghJ z9G!pl(9llAAGwo3+rH9ju@0NS;F=;^MqDickF#`%*Xu&?fL$%w+XZYU{PTShCqTR9 zWLAj(X91u1iwLFx0pi*FwDhKa97LQw%JX!Pq|9d@_|+f*vJTTO?^Z5^f6dKSX|QaO z#*F*pDM$j78SE%kD~$1qHd_I36Fd$ouw2#-xbnw09ve3|0ueJ*JnJ9gzw(-d>G0T@do#_GFwP7M0eb_al;L$4d#n>z7#n?R%A1%6 zO(ftWHsyW*yZx4@X&BHw&ct}iXWX-~5#e?0MxM{)@aUMk2r03smHjYu#r62+BZ-&aYngpC$v%umzsmczX@qFpnRoKDK|7<;Un zrb!CKc*b#Mq2YfUoE(Fo9kJ02)$326U~hf*=@Sr!Y#|zqe;V_72AmJDK)6w8w)7Zr z_cw^KW7n{>KAT5=7_-rFlCep$h=UP(z4aQ2+@Dz=H>Wkfc)6BQy|+t!K1{#(Qn#BU z?(q9MO0N8fjqoImJa*~}!A9zIBQ+YCoj|QCq2%&Xz{&^OCSacEc-{F+klK%@#4~4$ zIOY+xrdte&S~BM`$@y^*^2XAH?q4ET)K3#1%iDkP!Z_n;>_?wVw@hW6hBm9oO@wGXo6 zni)5g$YUKiz&Pdn(|GE~*PNDc#YvZYtYbYJh#1P#3M(XdG^tchV(zj7K)l4%PlI+w#5116 z@<~z0iAy<3>w9YC>p^MNNzclY?#kpZ*BiJoAa~pQhQ?B z%oode?CwMo=4+TRMI-d9bI!^JYH{YX-*}- zFyV|Ri|hzNeaB{DjAEJxc9_K1hy^}U;yf$IW+2=0Z@6t=0Mh|97y5u*_#S0ClI()L zNhT_l(Yx-DDRB?0`H_$>FkGsU^E6e}>Le$E$C_NZ9uR$${~N~Ur4fVRix#CP@tF4l z5ZT2+MJo1f-bWru>WAUEHj@LFCyV(VD| z_V%~if5`uThyPlwKd1Z}PMPOP4OvU1<5tQ8A5WjBS-U~^c+ea6e(qlW)cxhM-(@?j zRYNlLAow`t*#q#bcS#yNI2ZfFI7x0o{@~BU^ULPN?hRi)kepxtoTuTfpK<+rA$W>E z#!0{*hz6S9DW9ZVOzDGWnd`0ajmC7Z-<1+ZiRj0x+HU)b`zcR9if_*S=0CTZ|KVLW zFTQ#3gYji;w~h4nAqsDG2M^;YtY6x7>mRRL*H$-bLOO#%@7*z&3YqR?yUXUqCP23a zUp{AFG-j)ldhMgwJl|cwXD;pO?SFE2JmN!?Q2@Ey z{@D81I{)>fsJ{H`j~CxqHT&KD&fc)Qe>oU}J=En<=WmyX-Q#z|_iP6))RKDp-G1M# z_rvyoyiyg1{j=k}4wxie4$ck^dVga(tm=8*U$b-(BOmu;^-X26h>7^qM%>pbj(tD# zYhgTJWOa65*^rz#)%w|w7MuzjthLGh^YZfatlz!7WScF#zdmsPX27!=@bQn8*5)P~ z_;Hwp|AdEU%ocH$E(F|EB1;$JYyphc*oQZdyo17wjnhPcpaXRVsI5v;#SvYBgaUP8 zn=La0A^Sk-$sT~KAy6DZIAOJ~sE>yS2HJWsqQu`{?d-5BiBPL`_2#%u!hmhPscg_} zj{P_Y19+S6P^&itQUj93o|4=lP%vGLudC2wp@tXn_&S@iot+)FRqWQ8@Z7T8S2i{z zly(ttqq4E#d0qiC*x%TjM(gXZbHxg$AK4Z@w%}Vk4+0)r_m7ngcx652HxO-&TloJ5 z6P(8lcEgwN!iuy_r9+)ldeFPicDw zrDPgF!y%HpFj-{t1-!5CAKz3qKyF#1g7X``Y>+tn0-i}HI4Tc5gxuf{8wYU9Uol?B zMOM4vOIc@wIs&K{KsL@5)xq&@05(||<2-FVR`S(Xi~|<~#`36lH?=F-M&*kLxY2mC zG@Q+Nz;@(J!ta`z`LATDo^2!-eFV&dwfjT=YrVR)5`i7Kj8zOE#_xw)Da-G14D5nJ{tfL{xv}1mxha5h(N=i2pzVHnKs1ijqYn- znxXRmpRuPfu4xhlJgq69C2i&J(Tn>VzI=Rfud#VFGdF?_2eY?kFVKA2o59+U&)7VQ zvZ>u8sZ+qyv}z6=?Nmu&|GKm0CNB^px$9?sdV|X6(Z~43ec^(C{1}f+<&=Xf01po3 zHM5R$XiCr$I|Bp+LK?u4pH00n52IQRc8k5LyX!$?6<|PE+iF-~>W?q(sjo-3Rx=N@ zX}79B;yPBIn#dgMRG7$>7=HW1_kdb)PDdrw2~RZ9REswvi0V)h=htAhMH*Su{Va{> z%kD0Ne;w#+&q1NT)~`=+)E_-7C zTAeOYci z*!t#0tHG*OiBrp;hs_(l6h*xB4Ayqrzdn4ts@HbgAQB44{9~)W>(9fF;u}40s&k}b zX2+<{MCb65eq>`HO^AN3%g54PWG$*1wiNICDX3$1n3@62THw4;u_w5nj;D3z{Dk{@ zR~?CJt_3`P&@}1cLA6p3BYE@ZEAFtW18gJLtWe?63|>~v5=k~!%n?cMaf({4Kl3=C zmvUv*n^}raG664($|eeSZ({_|IP!%ECm<`u zUZy^Fk@7LN^%U$wU(Cy5kun`R0PT?2RTP&vn5}sKOLWZIKJ$ho?dqQ{geLX-pPJ< zz;@VqWuw|V{<(A5+rRAe-wmpb%0^Wh3LT#eFAq-6j`!`4z2nQXK^MOaPfiXmk9q@e z)WHt`9vbzI2gA9mBI5s9*$5NbjocGr1(~p&pcr4&EpT>pX$qbZi+6j zVU{A_@CXl&0R9SqONj(L$)p$nCm6&SNm_$S;KN7{aV9G_NMt~R7mGPhYqAO=Egx$E zpM)_#O_MoKv!xtb4ygvaCv4k@8AHx?1FFjBJswg0P4>@r6egxNN8KR((zXX*js9}*r!jsCcY)Xic@*^d3yoj@8F$Ski;2)uNlFUT| zWJb3kC|CTzpOa1%jCwerqwQ5h9Wa{1eT3#wMzIMS2oxnS0T#vTcmMlYcQCx%>m2WO z54-!9{cZ;o|2yE*f^%|7Sn{CyW>uS`?qJY)*R>k4ryITR9S@-e+;yteoiB7tuoMGX zWfJ>Qv}Cp7IuzHSj*Aw9y#dTzC{XLg1Gb1Gvo(wPOG&g-BCj zhZm8jchd+pczyu)WC#u^oYg?PjzCi$abJKseN$Q`DFa_9MH8BIG0)&hpBPgzx+FtT zhLVa4Rtq*Lk0#*IyNSk5vNl}B@+Jey!Bc&YffEX99LY~N)&j4R& z^mv*!fN71u>NH|Z^H>EY*YR<&ioi+f1mS;+-Abzx7 z@@raG&ti)qtOC^pL~;_#7>Lph86 zTR)7@0`Dn+R$UZj<;N)cd`yptl>5Pc{(GNK6wF;~WU_R{xMivC4cYjWh(ak1yU5_H z)q=a#V6QoRw!^NHB;tNtfU4^d=+Z|)Idrj@!xhDYlXx}KRwZj%#|9N4+s@6D9`Vdq zAWR#jw%7qOeZ{+R8jh!XM1&9gFyg@x)v&Q`sLh^dcr08bpHZTlQGg^pQpcJe!Jv1r zdomgiN%f5Ch7JztEAS&JD4-rJD*S672KX&^r36*7pL1|aYM4%d1Q#}P4nVPj)81^Q zTsu?X)VDWBu7zD>+Q@ZjJA0$D@d)&g7V2I1xZCgTU3UN08(J(%>(`HiZ@{+#v~_5W zh7x9A#XX7lYw?RX{Tib0T zGV}|K7xbRZuRpCQ?nz6xmil*43eO;_;E8z@X0__a7{r%GXcs}P9aZ1xq-bkEX`R`c zGt|VDEP@C2?6un52V>9}g~#%5+AIjE?!wA%ZKz`uM8$bSL1`XEy?brQ?uIlP;#XTh za7KD-8Oi9Yt*!c_=XoR6w)U~n`jNcPV#LaCH_#Ru4otq~&EZ6CquK8()x&I8el#o= znj};<*|DFd$z9GQVN{^iBYY<%6S6+N3sdx>0wEW^j3q`Osq?R~s+5OC$r$%Mphgzn za6sUZAB^bzn;Lst?T0(}mtb`Z&t|YD)tn^&2k|Ajih%TO41OMPR>1pHxlU$0o#h@a zD_JTdlELMtX)@Lk>$K!9vwE)#n;Yr~_uz(;+F4ikE^8JHBxJE^Z&xUGO!|2QpGvUnHtGTS38<)8JY^C0!6^aAluvHGQm!6a zQVdC~8*0^QkC(=od{)F9&kTEs)qsr z`$n*xfZrzBw6e)4SjiP<;Bq5f%)t=fmymtw?}hligqL`774ex2}4@1QNA&D0LQaynihl4is}*A3YSZ@EBqy_Y?)JcfJ-ziail~6ShEbR;**uBD z@e+>|Y|Yat_$a(0xgqIbAY|Vp1B-6tZBZVDj@Ll2qor|cQ`_H_8~bx zxaY*wp9Ah}5tA#I77pHVrMto?Enp}qU6{QJOx;g>Np4vs$|iZ?sev1X5_oJuR0o7g z0zM#2{2GD)S`Lgxg@@NIiV>4V6e#uDQUO5OqIyEzO+zGfT#Ws>;$T59ND!pUhbXAy zV3EVj5d#nVDwf>k+h0&|g-6lRM=24#b@D?|m<)XR*wVsMt(f8brIbMJP?85*8&gfH zB=-2~a#;_(^Dyioxu5*6|M5R7o0ewZ#lX-hfnI#* zlnP$kGSUeQl{_iI?@X#d(s!8?y2OB{RX!tb%+xd#$pbkJ`A3;R?aTXtFR3wM~f&6 z=MguS!*cN;r~o~>5S4!{fWkOi7}o}^?k6F4wyvw|w};K`Ewm6z=`|~Z896abCALA7b@?BU}i zj8PF1z{E5GFBBat*zzDab1PP%@IapP)n(i}(0)fL82m@I^k3Qr2ZB$guMGh%A$Z}ir-2oTtk zwM=|o(I9W(XUzn9YiH@wo`bxl$ZA?J2bVnQ)jE&-7!>IceFY{#9^epWhD-w4k~?#q zla$)q6E?TpS^>XW5dBs|w-{`YY*YLTxtSeuW7(|Oz|q7|i7RG2^8-Kgm9x8YK&C;u z@JNejK%K?>6JR9O*AON_sf||*A;nWc{*<{#gFJ^q5h{7H|7J5&Fj12+VeU(n zd>HPdu|{VM72-xt4~aZ$!x-6sKQS{+~MP=bwH@|IhEXw!Yme#Q*)v zt8f4C|NI^P)3LMY&^sr-!7})$PrMAKRrM@K;p!U|erudA=UDL8lpo0lh2y7pic}-EG31JVpeZEUI4v(030d~!Auq#Wr zJN3o1gHcythCQO}FrHdfO#N5ieP6A6i#ag7HNWl!AuxfcUop+!KCk(e(_CI(vYkPd zkVLE+1f4_!E3K$?s9#)D8NmExgY%aD=EqOvAG@YETF*YXj(m8(>h99VTmfUVAzj67%zrjKeoOhFNQ|0v$|tP|F{x=h$HfzLhF#%cq#f6y<~?@ z&2G+rE>}&fXzW7TZ}RRS!0_b_{4#Ib3laaGJ@%Qr`_EU9%Jpnye3k^iKO(|Hl z5%Un$cLPk<)ZW=^E&{sGQM%QnsKre`kgs332#mQagev6XEC$m`9)MB{v^P>*IKZa0 zlmY}MRcu=4~gaYR;c)O%X zSUgZ+8EaUr09y->%ZDPnpgF07DtT2+0KZGyOMP>x^0cg39|OAu`b?+uagQ9Yr2U5U zvv4hjU0)cG*53M22$D;{whe+aHwHC`6mCHK`mqXp-N&j7AGI9S+fhxqrHM`SxTV6j(!ai`(9M{lj;E`Tk9=uI6ioy`%2Q+3@lRDu2tr z%keCy{gc1_VyxyyFYZe@ePmLp;lWA&L#Mxw?gQjcKn#xZ(L-r=oHa3ZOT+q*f~FY1 zeA36=)Uv(-Il`nkx$p3lDf`y+qw6FMv*`>;~|CX&uglU$6eCD0(P+CgV;c)5S_ z;TQpzf>eW|)8-hXtd4PB_H2N=N&UduGVr5rr0;Kw^V+c_74~x$T>*79wAaRP$SdNE zZft1Esc9$08Kq`+2zS-gendf(3)LCgJZc&$slE#uk0xFU`nr}n1Z-NyD6h&1V9%(8 zj&E+NFDv~k(dbQ>C4ePkCVg6x&<6s-KOGbQO+F6=Ftr+(6{fe&!m>wfUHDGjq4^>8QTK3dYie!P7NV<} zgKwnB(qu_tV)Rd{UT|4ryOK9kObcP{aho$1%LorZVWmc{3N09?w;*#LuQFO=&=>Y6;h-@ z2%EyJ=AS|YL(kPBR-zUAv7qMH5B{4-;y&=dhBuUu2#}|uBqP5P3S1x%PlfRiOBAqN zb+cMUE*zfk_>8i6?&?5lmLVoPnF5jVsFd_URbt6qZDN79-loNsu1}dCaU}}DvjDHA z0er<}QAK86*G^5@9(KCWgLb~^3H^*Gu!i)!*Wb#Obk3>FBH&OH;`(Xy@F$KR7D;X} z5~ct=TmrHelo8us^s0*|VosqRImdhAhEV zZ7$9r{^;5zs?1c%Y7?v-kfDae3^MpR=-dG`m;!=xi*(=CA;@J02{18@We%`SM@>)E zUwT~V*z1;jgG}15c+A1`b@W&2`NuZ6V4#7J%oGMTNUlm~4c#>Jst!abfgEzG@W zNP~tx?}FdU3Cid2ElK5rrss z{6CuuFucmS5;~$XlKX)a+OkLW0jTBPd_i*43w?x7(_|J3?m+~Pb8=e`&Lk{CrGP@c z;qZ%6XiVIqTclE9VNqq{Vx?$lrC8euheA(upiyRkHDFCD8ykB3#fOZR9~%zDnv7aq zQiZ~DJZCB_Zc!zt$rDE>{<}jROSE12MluRNI7Lgw(WRrvV>;v$fm&#z@(FrXf7cE9 zcS7_xEbi1Mx~kFue;21f5miMw(PLjtwOnNQiqFm5QN!B1%o4D{7oeLI`tqi2U?evb z=1?*!`a6AqCqVgu`%*~74N-RcE5W(m2XNY4Rw#dSD^?G^vZ%eDKICrolS3^dv?_8~ z3fhX*%=44F4^^-Kg*#SZwRXSeULf#NdNv18x`p#>!Kz`e;HE!kM@z;6*J79U5ByD% z>cu?bpCGTYA5E&TR~*j5gldHRaS#G%zLfigCR+_BzX*D&l%iX!RY+odn(|2$UQe^q zi%cCiNMo7tINwBFCOKBMfP+~C_>-g$mJZvfxE!toxF0{DtcAA5JGWwRs@LepOIT7Q zEv}kg4<(N~t`_Yub*>9lWL7RSO2vi~pVWzU6O%3pLOBXDhK?|4Sd@SXzBG)XFg|1{ zW2Z0+v$mZkYZLRzfh;IUDc_myNXK%5>~cTsLe0=F;T>6T58J~z&V<$BU#D8yvK&OOgtEmLlQ zFCN0r<3pWZ*xusGQM9mQ?7T?OulCQDng&%aWVsdpOa@5(N>zc_)vn~a?zYc~wbdOU zNiV!gr3ltK60*G#IgVW6@(3Nvgm#ynD}o?7^(#(6I_&I(G7&C%VNpI8#~*0*gevY^ zvdV@luC_>;*`l<#Ei2CGGez-lRzlYKfy<#jEs{4=d45sK0zN@e*Ioa`jj!*n0%hq8V{yOl90PSu}-;}S)C!0h&`=&#%9|V{b7}a7;b-Y)LD+OpP-g8i79e3HvDW-tW zQu*sR=Ud-_zbbdq&vl`;a~dI+X-(lio=)KD3Xt_YTCx)ZsJtsf%3W~kyV2~HmphtX zkmq1%p0b+6fW4`x*p1+IdL7qRpEYYs;}X-9g%ilvhm0RB0C7mrm3)olF+hHB0V)0L z>Ab#B(v-f>|iSj87^c7om%nC^n zT4Fd@dYQWYuDC*V>`#3cokynwDkQlq@ve3;~u6 z`9}!dv0DmW$Ca__YbhrI)=7uae9)ljUOk26#MSz)lCL$Y_4BO@%Mdn4ysTLoy|~Zy zXF`r3;?jiC81=SgVDiUytM%gEstRJFM-D@=*k>-KZ@oP{K#y0!eWMzECoLc*V1CQ=O!f7e#YkDT8G$a>DLd_GF4{mO1i zv=mkA8+GJnM=k3K1J}9QMN=_X0R61C0CHwXJ&1}1pt{J0R$?P$gOeGCOGPmNrsh~; zUj;lzU5Ie=cajy0XGI`$ZIdx z&aF9#f?rdRk{y=UIhuJ^mF$pe-vb3!1?SroF~6!Nzv`5JD@)ZnxTW98Qfoon3a>7R zw7XwltFNmL-|FL0cKC?ddd~TKj(mJo>tc^B6@TIBu0Kc8<-j;AJv7#wtr?PSZe2v) zaQa2BAzN+pgu2GP9jwQYbwQLUFUz5#ywZYUSmUz0CeJ5Z4Ob6I*$JHjsfYtKYALX`HX|sk>;e2BJhBtLCG8>_!~us>&P#iZ2kvue447 zVyS?7`#nd%Zm_rOuCzzDhP%RM5$*IGsl6*F%}MQZ64!DGYB_6xIR#cAUFw9}c-pxZ zC(_PFY0X+$I?AgrJV#Hi&FA7!t`&54>=T`?l@fi1K0S9z3id@SO?{2*!6}2atL8;N zYne?c)G9bMsn_o+3~liO;XyLlQqz`FE8Lm?$uhmolH?FfY-QkZ%b&^uZ&-dK1fpQN zmhcBvN!L7sXiZ>aY%F%Ci}(fz4d`D`I(Mbt;5+D|g&XP38=IKiGe~qJmmXCo#SE1^WnE;JRa-pEsWeNe&wv|q zK!0&IW{%@8(5|Z|iE2*xRHAp&vR3KL>?mr~=UuQ^M#B}7HacCRXe`Gth|HhOVfOd={syTHLHLxs52H>oN(agxC7Q=SlVRTRHkq9#nPg4w-Is)I$5gLuiSFLEUk?NZY z&^sNoZbgy6Rv0x)cA_H6g(4x#kp|{jf>i)>6iDoZA*U_Pk>D}KP_^P9=cmG*1DJ8? zk!{;X%Rgy_lt;K(A77OCi{lE(h7^AQ@$V`0+pH>G7HG|+S|ReUF{MuqBz{*X1b;{b zEqm}fa|#^1O|v{6B>)OlAm6GAq~auT`uDoE=6L_YQKoBowRpGWgykHGowKm(Njau# z^X}A`4IE;m4nR2$H`9XML03<*u=GNvmU2&&aMmG7k&}xcp zTlB~cMK#PT#^FqryS3J(ZNK`#zCRuROb6BHy568Se%?;pFq~f%8BRwZsjlFj<2G6{ z_%*LrH4|m9)C>6Gp|q==9S3z6LoES|gM5G%8NtELX0f`SbtqTbqOXeqBWGED9Q9V= z`WfDN)}pNRjeg2!+zztw<}=4&AlhL(XaH6jCSZ0)kSnD<;=GX9E(Z8$v+s16kMcO5 z1-IwMlb+BeHOS@rkEoviKaKzCc)R~z@jtg-Z@+$>kN>&--5>Ekf8Y3@AdHd;{j89j z^k_6eIq%s#oO4XAQ&JCn+`xg(iVAwqhKGYeg7$k(i!6d#Ztxki|1y!6(mfqn!4gS2 zBFACBv)7dpW*!G1tr#EyuB9N?d%gAA;`vohZPfKh!%(G=0m#~70c_D$gWOsHvfvm; zfp#b^LoG`_@GCVuWos(KM&PXk$^D=knE*HEto3HSpjp?}J}3x~OEGp{f~@?4jYkQM z7aH+c$`tJp$^ps!QBxbrgGK+quWBwV8kTc=m&_y$O!B9>(d$857)jHAV318d5e6QP z>gfWiaxjfxL4xkRn3TW=)f?IQlW`FR8xwhxQfDVvifW8QLVLv%wArml9p(h=N*kam zybWczTW7PK^$%5WE&dNQWCl503+!HA+*3E&SWiDcP|*(cwqO0l+w!)&?J61Xsf`M+ zi$xlF3jv{WXI%XT*Wx=&R9pJ6Sme}jK);KP2B=~uk#N0Rv(J-X5n?ld>cWY|`=^<})l5tuycvWCqCiaI6ULCS4!sCW zXVU%4LzonTM-sRVs}h#hTAS=YFE3Be`rXS*w%M}H`RM&xNNmq_ehV_?k#_+msjDK1 zr$Dy<@RGPzl{i!zt4FAA1*krb62YIUtY!f#2ldsJo$${2=~?{@DT#~pM}~m^XZU|B zM8R*_$6Ee>``fMWzS}PF|KDzH{}KQHclb}U=^^E$D`pF6Fp%M|$rkb@X#&=z1wii1 zDwnY`HKrYS7#xCG;~+~?NRj0{Eb!fMIZcuqtJjIJt4y2+ikSO%afALDz?;KOS}+Zx z;8LCpyV-C3V0uQ11Ir50#OAa9 za&P#PE(>Hfv9fh*x^8G#YTU6Mrr?^}2BDaH^fy6*{JFHLgq0iA$_d;sd4i?l5i-GF zEv_4E5-r5^BpyJzOF^!w2;U7C>Aj?-X~HnPjMP+SqRmQKn|+99RR=4J-rz)eAprgnzvWRI^t&;n;lAbcXbIywJWzJ;E8B}HVgf1v+9C)8lXXXo*qx4&rKpLc7N$+sy1F2IR z)YJigE@GUCH=|CmTw15d*{kb99N``$rYFNJd)$zSEnnx_t%H^qTXpsg+jiH@qqI<< z5vAXdFoPeGJ$GSIA%ZPthevO%g07x$e*74ZEL|c7pmV#TpVDYt8T=r}Z}udmsHd58 z>NhkNWDg_@!(0A=+`YOmY@7yls?^kyt&Fk;GKjfjei{?Ieh!_I(SGHizjCS-nT#Q5czXV1uLfT z2uN8JW<2#Hm9I2Q7hw7Xw)YT&gQpC*b4OYs@b!Rud%$Ce7<|irhR2(rVN(`SI($^s zt;KeCcQCGmI-pMJ1vaYHB0d3SE0|g1Qw#r6rcO-;#mYIw(pawUHK*k*J)W4G$Bng( zu2A`5mT@yRYTS5wdh!bA(k`T{Q*Ls!D6(kSkatPXwe(UBX~AnUAG8G-XIJN$Mr1ls zRMX`RRz2@`_d9#T?*0Wt#&QICWv_Y@03HqhX8ycZ17X6F7P5T6SfMyzO{!?9#xvhI zHWwWJl~1$$DOm;Jk-Ac(k&|!`+Jq?N18*?I<^u7h51)tiMQ)MT(GyM6fS0o_;2;EZ z2;fn@AVld}O_58Nd{dTM>6V?qfY6{i2vaGpnFw!o6ND-7pzxWr-Qz_n!dtGAHY4d; z3D6G$1SqYJm2~DHj5tDd1_3^*+D>wHYNi{W#ymnsROaN64t$CMeS30p*zFu&4*O@_ z%bz=kXBZW}jD;BR4C3llw=r@#YS!V8B^UT_5?3n_NXG?x&^a7D6E3REtEivFzhY!S zs>x(RfQ_CQM67RL0qaM}k37cvy@ zhpGKSkpR2#t_Ykr;U+(w%mWi#Y%OHt4^RX)*uq%Fx7F>- z1DvOecyAWe#WmIRB4du*5_5mdPiDbn30 zwuKqc_=yH8%?A5FHNi=BJn@7*Zb^-lL5PG$tiK;lQ&DvesL@nFznAAtfG1ic6?=A$uK2^AsG(+)@Ll z>pB}p+)pLSxiV~m+DMNw91+t1tBd(h_bjqO@3+u3%8tYmhRL+G9}Y=LC0&)w``9M zpV%8Zd(?(AZD3yl>X2%Kk+-cowDO6!&lc_dT~e9I!x0MlLqXgn|7X@#rw2rVvT!Zs zk+ESV-$@NnIppNZFWQEdm%okbPr6Lq> z9tlJHd8{6^DCKi)HaC~U8C?1CoaK`R=>jJppsZ^fH!5o~d^s7GNy!0~iLx$6Cl^Jn zjl)Y9g8%KGoDH48cx&9lMlbHoNn`?@)MoCyeWho^W(PX*jF7`ed2 zF?<#*x|y7*C>iJRN%zpF=sR#tIawU^V?E5|7gUolv8{h1jE1;8E6#CHv4#z>;0@ z{p2cYi-}MZUB(oG2rlk5l z=;^yL3=3YGIY3g4533|uNjcr|Euvx?mT7-%;dtj=_jq`@-~G9_*S$og2fC1`jcfIQ zZ09NrVbOA(8<8-1-h~1`mr7@F8{}}u$DcduoPE+)61n;+1F!p>MU$;q)M*YuHQpgn z1DO4IadL+^fyh8w2m`}011k<&cwlX!RgdvDv!3G)7g+$DdoLHe)lUZK3?@;ML(5)@ zm{3LF4TayZMrl37aI4S43^J4x=ExnCBmD)vk`2;5Ss1lh6?qqQFJ6N}`Q%A6rTj=F`d`!CYpdXE4Q*j8JIHjyYg8nWV?WQ(J{njAPD$of*Y(j@F&Hk(KDe#Aen0F8*rWp?aEGijYeS_xZSo{(w3OgT!&=h2VrpR%mc-k z#pTZo%M-i6nxE1Xh1&z5@RBw%3OmMaj9PC({!$2W*trUy=7U{6<#X9zYF!w})7@fLAe$Vyi#a?wIfSqggWmDG;?sm23>Psf0bva= ztoC>oKrC&7T!Pf|NA(19#g`Mo1eIpGh?#?|yQu&^Mk`o}<4mX;rj&doAN?6)ed-OXyEE@pYrs#b9xV|9@HaWy_pd$|zId1bkp~p9 z3n>B=Qzx*hP*uKc_Ic)qk-%&42_0+MZXm-xBH(}9>z^H8;z2z=8D1WooE`694u<>P zejjX~K8@2w+$JGu8^XE(K?DyKh=je!!YIs^tXez&wR-VQz1p&@$gm}lj^iXdfK#rJ zwx&r%yGy{kcMT>Uj9oR{_0G31OlbThDsyHCEJGxtbaBm-3{Q`id+=zoTkW!uYY6n> z-Y$O}fv5_DwW+UY?nvfQV8F3tw!suA2)X;EIj>vZyW~DDWVtd^u~nJiL@I9h=CkpfD=0l_mSJ)g*P6UeK4>hfG%_L9RV%^Vp2 zl%;;2C8tw~QdA>cF^AMH$emeTWK%1Nl_p9?h6(myfPnma<*9S}E2RJUGFiDb=A8a! zca29}t%O>ePJleI_GfWYx#W_ct6nvPe62=R@oVl#bBx-Tjd!(J%N1uiUHumFEK(4z z=ld-dzMSnJ&r}lo0*jCCU?X%8r97t!AWis&nH$0^%%Ch{`?fh_awevMa@pQhe(~ z3M5rnZvwsXpc4l&c!3*&!0k?&p@EnNvGSw^iMB)2ApxxWQ>(W(ke5Sb-m=t^cvHHBt}yMwQ!meo8^(Fy|Mak zrpKzR@Fj4FhKh!(wQzV>?wTkN5+qjUk>IRQGYHU`O~qD|(Hp7_)fC=+pgOh1t=6hu zTLV%SSF%`r){7$Tqny>x;{(AFZ~1zZoCP3e<1M(N@yP_;uxYn6K~^?b>_Zq|H<+0E zDRFP-sm()h8pawW7tw-IHt9x4crb# zOFOqDnyUimgmZnu)I(#f_72pit>=uqq^&WqWYt0M-A}#4Lpxa+X%{~Oh>d~V!j`&B zjC~zpxL`;0MX2U_yavfL8BChaJlG!-dO{VgB&c)8p3yYTd6lh1(V&*wNU8wt2kIO) z5M-5;+hiX383wLG$GW>P{`Qp&aJF{SYFH(^%Onw@S`MqGLexsL7;Nhl+WyB_2RE|r zL}!)9O-X^aq$g|2eXyR7Sr|__NPU7P7*()o5<$4G8JPR~8HW7^)l@QfPJ3mOUN=x1 zo<8tcO|mJN@yF;1kbxTl#$XUEne$l6S3-X-Pvr94ZQ;P__FV#N8p2GQKDBe%v5 z-r?N}Y{9*4N^08{E2GX8Cf3^aHx!Wi$-2i&tgP4BSF1tDs8!Ft^Ep(Ofv*XL}qWwk8Q%Nw~XY7a^FCry`rvobiDcfP&{9EOa z`2J)tygVJKR})z4x30ptHHVNbROLhOc>mfKREWU0xh$5db7hMeDIw z8UaDQ+$;#t%ibbQd7OPn(wi{8R^H0X(h-gCWE4jS!f_HE}xE1d<=!9?SJMe z#1|=TcuQr4cBk@|Ep4Q(;9@JP&km9LwEWpRmj|ET_gmO1cB825J&<`W) zWJATCmhpI+CULR=wC8DZ#f6ETO3+xq$KVPI-?6o?b~Gg5B{~isSinU9ABI_Z@mzs6 z#g8FTDb-S3+FhE&*EWzip)@Qd*}3$Qg6!4V@&K~9(zdppq8$3G_h2aGEWLM5v`JE= z(^tuQ&*;oNPR`Lp0@trgFk(Q5R$rzJwLC zv{g@$x#AfuAW+OzHMeSFAg0>$cfT~I2i)ClkDdLC+r0i{s;t$#N&bRGd>m)RFJ6cz zr%%TVfzT(v*91Ou#_ZHQ`BgVzEuJPWj;hV%PqxX4*3dd(&8)1OCW^UIZPGK~gCE^! ziDHv1BBieg60WPIjF)x8IffKTf$7EoCye6uWp@dtB%EhprGfefsj@KY3|L-JNdPa8 z(q})i3g=nXhi!1OWV?9PvFToEXeMr{bB6=^1`Phy zgCyT>gz?j zeKtJ!;SGCn4;Z-Ae)NuK&Am`(a%auG$QR-mtl~ewrn!{EV9kRt0=3Xf2?j$&&G=P``mj1xu)Z|g3M`YLbY66)}}w8d-HqDVScycS_K^IX`| zPp}Uq;`+w=t^YY()AOEFc!fHPTQWY^4Y!;dLqLUOvrn+?&W$cJWS8`RJ`aP7RZnpb zjB#tWff?w`waFUZnmaqN-AsJIggr4YOG7yIG6n zV!uV9?2)LsTSzw2{!^B3K>1XZg)00!k>eM<9~=>0$x%}ulsHeGtT!u8d2K*&pUsgx8aLB{$E6R1r+CixZtd#;6T-3(aI^x zzlLYYLplmYX0v|EAgd%DMQ$@{n?2Z-((P?`SMJgJ=WbH9L(4bln*CW85~2_yV-21E zEHY2*32q$gex}tl7d8G`aaqf{?zLUEE6C?vU$-U8wtBsuuYGxP`QLom>X((WDy82m zm6KGP{wK+ml30YKsW4GH4SkbTl#t}fZBp14U%o5yFS-29Z~H8Nn)`8J8=R#{l7Rrf zVQd}kaH2-$ucB#}$o>_Kx()#s2VCTc zKw4F3!&+8A@SUq2tWXt(UK;dm(?azfb!52o& zhC&96YthN294{;inYES#R#Mx@^E|btRJVR%P>~#dIY$Aa2Pt`wf5HycF_J!^zriWa zL($kV41{YeD~1Ur`*SR>w17se);F1k_ssO#wk;~iA_$GGq$Zl}pof6H)k3rfq<89S zI|<7!Qp5UY2MyUvMDnUinIWH#5ewug1QYs|99@YGNIq7)6`3O+lS7_5$A6zH{xu&P zl87eRz(4*Af95p*}WLbJaR)($CYG_Lr$6@mz^Y;bfLAOwQ)|x5gKaf{U+` zs||h`>@Fi@WPDAz<=-bjQQz!8fJWqQur_i$K;5E zi4F+uYa{G-)L^4eKYV}r{p(S^!S1Hvcna1hcT+z@8QnmiduCv$*_DXT8G0Bf?WOtvpa?^|hj;`)yQy$Asa($?auVht~zUBTu zqcFb75BU*IpL}IlW!s43&h=O?Bi9pQBC*eTs?0e_xdRdGX*_6#lxfSYC>ewOAsdOM z7-!LlCE#p=W=?UEO`!-Z!sT29&~(YOwTpzZBKx*2S*u-)ed0KCOM&wZ7Rvv^=I8Ndte@tEHRwZ{67rMntfNm+CJ9vDoBuypfuSAGZ+&X&8K%zt3 z7C8aFGxSwMCTdj}I1F0DE!vt|kbSFR4i|KCwTksIsuE?g+(%_ILhsY}uOaeVmFNe& zB2;Qs++2WJ}>DE#dC(gYaeINP0G8HRL#&-2H ze34dc;OB{B|35PwpKGn2K&}8q--*h<&e`Gc5?Ghqt^D2A)|*@{U>Hj7 z?xIj~egeHJ=>s}|icW?mdW(5YKpK>lfn3F6HuF=+>#m%|{rP~tSvF?#BnroFrBnQ5 zzxOTH9kX|l_Bcu5j`IU$HYVBEeGYcM0bwhXn3ZX2QwS9hZLNIJGunZgLg zh8mD1a3laZE|U;L7YKqtN%OE7s_@F%!Zl8n$<0==t_qw|l3M^Fj3)tcmA^W78t4Dy zHG{Ns0?Q`J_R*nkC=?}8V`|_E#!D797Z@Up#T=isZMN00e*?$cX6I-D1#EE``=*xp zU{{+_d?r~({<15;5lO`~oF674lhvGWc4cuB&gVQhNYaxzk3aCMfl5Lu1)gOw>Pog} zZoo+nn0~mp1#l~2kk9jrx~1Jh9r+>!mtIR^q`rVrt$e@di%bYrHGjhr$&>vVI$uFG z0EEN(8xYQC8$^zztjNgp%oAoyc=aL8n~~5#0aD-`977Al6e7X&5dyqClSS5}?FWxf z>m+$MA((0t0ELY*L)0e~jWwu_F%ArcyL$#V;ruzE>}}jkuOoQ7%B(&L@j(oXj+#Ft{3`XkP-g+)!3gA@$W|L)Wf|rfN6ay(C zJEeV}#|ET;rAtx{J4Y0s0&eNo@ryZ_t{gH~iqbqa|MV^2^RqFw9~hJkk62|sONdo= z`=R2QAN$ulXx{Ow<`vimK^i5H;}M=NWhzO6xptc*RAB7`#Z}7^!oiSq<(w?{swpUJ zWA_>;-N$)%!Tq~SE`M3yC{gIg{#1>ATOn)bKN;KBom^ogg%5A zlg}nPVI_@~s7$pektN6?EvT}DRAw9vRgHwR2T7_ru!I1FjC73W>xNSHep)>SV=}7H zA-$?469m;jPI_i7f_d<%{bCYlgYw|WZc+^y%^YNkz2hOgX|*`CWKo{8mS$VR zVU|Qh?+5k`p=>o}#O;tD`8y*aZg3uV#4x8kxYLlB+)3z|->-WDSg>^RkGl%F( zTAcyGf>=XYu^q}a4lBELl>sZI!S)y(kGC>aLFt*2gZOtHDFgmlCkkgSX!?Nfs+rVt zxDJ##TZEVES|qVIAHV)u)>Eoy^3x?W&|-*Ea5ktiR~D*Wb0E?>V8*bBYO7X|kvp9sicSkqZcUQZ4RBEE z+guhU+{6jL^V3+msi{EWw5FG^rk8RlE+LvECwp5brZ*+qQxivDTs-NU43)YfsK>7K zyq<9({A=*GkjBw83Au-cAm?ueDr|tYyB30iP|*<0W)!)+&Cgt2 z$CNgkl8LXA*WIq&U?cCvJr-a2f)~mkq15mrkIfhN<8(RC5-;`RAeo(=_4aG^M=zVr zM_&}lA}Y{-Aeq4AQ|j z`6(>Q1t}+TQKgF>ZxogmXGm$KOqL@mx7|+hAtKf_GF7bXAovQB=h!Tp>Y!1W2*;EN z;LEo3_Hx6$=*PVC;?ft9>Dx*MN=U95hDti6E6RRk%N{Y%CRU`{d#aTPWqtXC#IQ;d6^QA}#}J%g5tEA$*U+KN4((;nu|}wTmaVvju6UB@C&@OQ z7q2q=p{dl-CebcQ{g47_c{j_1p0;pEuBWxSa%r{v#sJPqab=zhkm6D|*xGrHLM7D$7`z4un3gCX^T!1MXC;v zoNZbNZ6Vub^$fD+Di`Gz1y2f}GIUC@$swb0$&qm> z0Y~}`LSjFRnLJ%=oBT_S(INBG)L(-6Zjp8UtZ7r4Aa~jdM43EpjTL9kg8cvd;^p@K zb54%oYCyyvg5a0b669wRL6mBdiJH}F8Ug(WR-w^YYKjQXx$VgV;{5T z%>9(tum%>?ZRIQ}MnYi){wmkZT97^GOL3pW zQ_3E|ZVCfv4AEfBdm zD_q~!EXkfEw}LWb+%wn#ebqs6A%v;Ce=&+F6{~tGv>-ysJ+YyszAD|e9k{k3O91>^ zxK&*nh1IVWTW0tfOxi?vix>-tn={%jMc=SnQg@3~N@JMN335u^OOscuIpVP_o^12NDM z?m?_6&B~>DJBj#N=d|ZaH?=a48v>I5{l+x1(XK_G}Q|c1QWZrBhuWs7pUo9-=nC7Q8y|3 z`fDo5>2k2#aw=Dr>&iU^MuEY_eT@C17%WP-xZgWC==QtEL(BV%?ei$S;;EnUXbFeE zvWapw%1;1enJBaPvQa&@8UM!u0tSJz44JfpKNw6Irr6n(EqLYFM1CDWs z7V$hyZh7nk{|rLN;LKylo0DkEd&JDo7%J@&2m%^X-h>zeda_w4GXDlTA-*i7h^2Q4 zK0c@Lf1u0(IR@n2f)GS^{?gUnI78e=EQ$*UWKXXe+_#4R$@wPbC`@iafhlm;vt-o` zeErP|TaQUCLcs!i5pfGNlQiTph9eAPc9mpPDF{SaFY6!~78tk$_jhGe5h1z)tbHUe zom58EH1fnC6kwABIX7^jWUh_MCP@LrBAr0kmM~+)hcDur75+x-OhMS@=wl#1agHY81^At+I3NeNJ9V$-`1$qThn~=g}aLa+$ z9;`JeS#sl6dngFy;KGdd8HCeH;(*UoTwaq=%d4uBI zho(W7k|=9W(yp+(ilycZ^hfj;_LmgT%#h1{k)691>7j@|*b5B0c-K))0-VL8Ub9@ z!lYnB-xT+Bdb1S7C>y{Ynowt^I#+r{B~O>+WjQVuYg5Eig??eaDuZT3DqqC&!p=j= zukyld;Hw2(Y8~h3J;m&!h&9$kjb)S>3Q03lR}@(gdQVMY30j{I^HM0y@^r{+;JNR+;U(N!){@CnEqPU~ol08zf2cj4U(5eFZTz+f|EKGQA51oj zu_0plvR6Lv6w5&LDoxCgrrMmK%WI>ZA)BtVtu^f!6KtGS39i~b?pO0_uzOa++6c2h zK7h|@dxhmtYdM}7E#mCmyXw#IXF;0?uLQ3HQ1X}@9tmM&pV3}J%O-$;7|%e5q4ge~ zr1S`*UNgr3nt>=M#Yb+JxY&l3_1eW+7Yac3exvu+X|v?_i@Svqj436$RU`&x+^^%= z7~F97OG+mG=_8h0^GDUC@8?R89#Oabh$>CMXr>Qtp z8oogf&51e9q=}v`p%bZ%KX0Cz=bRl=5f~&^K+8Y|GKRFH*L;oMzOG6JAiZ|yLv>bZ zxrfOgwVx-woomn-NGVgcG;jN;*XpbPS|8T<$KlQK;EMNDHFHMHu;s>s&N$y?!Wvq& zM?yD_%Sh=XQleNDRzsmealZV-i|_w5uES(|)DFP1dXSut{nmI}QUir(I0o~_b_pT) zB*7uL8IKe+{oe#P1)>S+v2cO|L1%(`Fd0s;UXA2e>36fb6$fq6QoO|1gsHD`pP=bs zDQG=B2d!Fz;Gk-z6awYObRz+iEx0M7&?m`}oat4or7dU)*bp2<2UniBwHDlZVby|T ztKK$TYE}+NjqG z@An@)eAt@a@1LB0`=EcPfBNmC{>jOsNBxI)`u&rWQ~ba489rMu={x<;QrB+SgYMM}2&Yd_;QTOAKSMKiw1QfU5ra4}X1 z*C%MG^GlNy-QV=1=O28e8xB)FC z<&{t=0wVCvtwFTjjHp&_-+nc|Ln}hTm6WR>7$v!8xpr-DkhxTw8bQ*0!{~lim4Io3 zp?)wF3K#+)dE%zgSFaG~iZ2gH7^|xkfvs}dP@qbJ;t>I<%TQDW0&kJFPE?xHg_$l% zF;yVsJ>)6;wSIMCg6g-U(lYOE`Dy*)4BW=KwqGIc!E; znk+FZVwFX)>aEQ>EfM~rTXDc6mf_)=ksD|agC{}p0bHI;B33aZwX#+`K@x)+mg7lCX6)B{H_T3|A6B0(WQ7veB-+Mztw-lS3IRxCtAU7W)H0O)=>$ z+=j&bqhxP_^z@oSGmZ8w(OkQ1EjRn^Gm(Th%S%7v>hkhtX#OqxUn^eU0}UPb!+zLj zt&ol5WYKXJsIpmJRv>c@**B^@8hC~IwBYc4Xq%+^(V$v_{5HtlO6}U162*^mvzj0O zLYi0ZouTBLk2e(=ymHLy@aZdDn?i@%Y{ks2-p<^$GjNr91hGj?6?NoX){8)j8~?ES zu8gK2vN}-_ZapFZQ$(0v3toZVUuRZ9mS1=4!q-c4WimVKT|I%a#rfh_t+4j&0`24; zyfNz^O(uq78%bA2UTh;)47P$X?lElFCX1&kTI-(x1B;UsVVnxM@uc`(0m?~4|IS1J zS~pjE4#MaJwmoAX39y0uQ=*-!6ZOLfC*jFyq#i5#9VfGh3WiflT*=chWa6pLNc0jZ z*;Z(+)?PgvY1mSUT#L@qqL&-k!(e)m7HEPgO@?>LuAF%5tN z3Dpxss#2I}xF}2zxXUEX%nV2eUY$lM_dX68xo zS3nL(hv*eTh^%n2wq{_jO^rHuh?knSksIuUL$*>9ozx%m`<~vPYZ)`aJ31872Wn!! zlKUaa_u7q3M~CG=A$R%9cc}a9#gEU%6zKB9h~x5v{=d#>(CfVG_x^C+`-|@VJ?y=| z*X@ovqrqSA)lx@a-S;+@N}ntz^jMpOQ{(D6l$zV^$fKdVcOo&>JPe=oV!&Yq$@7@~ zOTh!lKY{HlF6UG(f;LOwJ>ErQ}8N@q6O*lXvm^GLivOA5)qdsS`C&JvauErz%VdX3+0 z)3NQ~C*)mBv5zww(DkXrzjUwFB zk?P?!Z#C`IW3~56J7i>R(ZmJyts|&Qfk91532ssuu~|xT{)WvwpJxAwlbP9tvv6H} zM*C0y;qUsV4g1f@qi?_1e?G%U>^O^}*!ZR!{I1w;JVp2>OLzN_oQ**0R(I7?ky)XX z%?v5)EplYL$$>gvTfI8Pqvex_0NDjCzDW~oYh*KEPy3=eMg^A?Hd94&1EGAV^6h4` z+N*uK%}6s`0D=#UEUB8gCW3=%_;!cxJkv>zp2FeibV2Af6bv7T>2zrdRcw>QtU`4@ zAwcuOAlw9ja9CuHq+=mljw7X!F%OTxmT`Pl-}7Z&Q0J}m`UkWZ8cun6pvL@#R9fP+ z|GTmD{xKT`)LnJH#et)s+mB3EwmvxOlBS;g$&%{bBswVw0S=6!1|9(CHFl>XC@rGV ze47(N;Z7f!@1%1&Z(sU+dj?GkucI z_D^8T7}05U0tv)>$$DvPeM)n1G&ndtLB_8UMT~nwE#S7vOvo8%zR66EDafa?ICCr7 zbzS z^c3{m-c`fx60~e#^r~2>joztIV=Y|eDO_4^zhlrtP@k~s6FfObOkAg7m28$-f?KST*g;wE8@9CyV(wnbv;I|X` zD$SPIbEcpY?5k3^#BeF^UGWVbyJuC*vzdWph#1RmdH}&meg$d_aL~s8uZKr7GhKm7 zHH)1$Ypj7~(8BS$mBdde?G3THTgrqy5OPl1q!fLpt2s1JX?;Ks7CG@D!X8xq%p+l_ zLzx$DO30;KZ%{$F+Z!%~d>-e8Nf5ORiIw)-hSzcq4;&YY+Ug7%DgKIXJ{58`A5{3Q og)derx~+39Ie0YG_3(3uiC;cnK3_g}{{KGz29fM`Rsfy>01}_n`Tzg` literal 0 HcmV?d00001 From 72c65f6e3f3fa570a14503fca01ab33efd4c0857 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:51:38 +0200 Subject: [PATCH 05/10] refactor(agent-device): split the allocate flow (#472) 1 failing -> 1 failing Splits the refusal check and the settle steps so Fallow's complexity check passes. --- src/agent-device/front/allocate.ts | 104 ++++++++++++++++------------ src/agent-device/front/host-link.ts | 2 - 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/agent-device/front/allocate.ts b/src/agent-device/front/allocate.ts index 29bf866f7..b6bc18f22 100644 --- a/src/agent-device/front/allocate.ts +++ b/src/agent-device/front/allocate.ts @@ -44,10 +44,10 @@ function notPlain(params: Record): boolean { ); } -function planAllocate(params: Record): Plan | FrontReply { +function refusalFor(params: Record): FrontReply | undefined { const fields = leaseCallFields(params); - const leaseId = leaseIdOf(fields.leaseId); - if (leaseId === undefined) { + const ttl = fields.ttlMs; + if (leaseIdOf(fields.leaseId) === undefined) { return refusal("refused: this agent-device is too old for simlock; upgrade it"); } if (notPlain(params)) return refusal("refused: simlock leases only plain devices"); @@ -58,18 +58,22 @@ function planAllocate(params: Record): Plan | FrontReply { if (present(fields.udid) || present(fields.serial)) { return refusal("refused: simlock picks the device"); } - const ttl = fields.ttlMs; if (present(ttl) && !(typeof ttl === "number" && Number.isFinite(ttl) && ttl > 0)) { return refusal("refused: ttlMs must be a positive number of milliseconds"); } + return undefined; +} + +function planOf(params: Record): Plan { + const fields = leaseCallFields(params); const device = typeof fields.device === "string" && fields.device !== "" ? fields.device : undefined; const klass = typeof fields.target === "string" ? CLASS_OF_TARGET[fields.target] : undefined; return { - leaseId, - platform: fields.platform, + leaseId: fields.leaseId as string, + platform: fields.platform as "ios" | "android", ...(device === undefined ? (klass === undefined ? {} : { class: klass }) : { model: device }), - ...(typeof ttl === "number" ? { ttlMs: Math.max(ttl, MIN_TTL_MS) } : {}), + ...(typeof fields.ttlMs === "number" ? { ttlMs: Math.max(fields.ttlMs, MIN_TTL_MS) } : {}), }; } @@ -228,30 +232,34 @@ export interface AllocateArguments { readonly leaseMaxTtlMs: number; } +interface Granted { + readonly request: Request; + readonly identity: TokenIdentity; + readonly call: RpcRead & { readonly kind: "request" }; + readonly plan: Plan; + readonly grant: Grant; + readonly address: string; +} + export async function allocate( context: FrontContext, { request, identity, call, leaseMaxTtlMs }: AllocateArguments, ): Promise { - const planned = planAllocate(call.params); - if ("message" in planned) return frontReply(planned, call); - const plan = planned; + const refused = refusalFor(call.params); + if (refused !== undefined) return frontReply(refused, call); + const plan = planOf(call.params); await context.map.refresh((who) => listLeases(context, who)); if (request.signal.aborted) return undefined; const outcome = await requestLease(context, identity, plan, request.signal); if (outcome.kind === "gone") return undefined; if (outcome.kind === "failed") return frontReply(outcome.reply, call); const { grant } = outcome; - if (request.signal.aborted) { - await releaseSimlock(context, identity, plan.leaseId); - return undefined; - } const address = grant.device.address; - if (address === undefined || address === "") { + if (request.signal.aborted || address === undefined || address === "") { await releaseSimlock(context, identity, plan.leaseId); - return frontReply( - failure("simlock granted a device with no address; nothing was leased"), - call, - ); + return request.signal.aborted + ? undefined + : frontReply(failure("simlock granted a device with no address; nothing was leased"), call); } const forwarded = new Request(request.url, { body: JSON.stringify(forwardedBody(call, plan, address, leaseMaxTtlMs)), @@ -259,34 +267,43 @@ export async function allocate( method: "POST", }); const reply = await exchange(context, forwarded, identity); - const clientGone = request.signal.aborted; - if (reply.reading.kind === "success") { - if (!clientGone) { - context.map.add(plan.leaseId, { - address, - deadline: grant.lease.ttlDeadline, - identity, - ttlMs: grant.lease.ttlMs, - }); - return relay(reply); - } - const lease = reply.reading.result["lease"]; - const named = - typeof lease === "object" && lease !== null ? (lease as Record) : {}; - const ended = await releaseAgentDevice( - context, - request, - identity, - plan.leaseId, - scopeOf(named), - ); - if (ended.reading.kind === "success") await releaseSimlock(context, identity, plan.leaseId); - return undefined; - } + const made = { address, call, grant, identity, plan, request }; + if (reply.reading.kind === "success") return keepOrRelease(context, made, reply, reply.reading); if (reply.reading.kind === "error") { await releaseSimlock(context, identity, plan.leaseId); return relay(reply); } + await settleNoReply(context, made); + return frontReply(failure("agent-device did not answer the allocate"), call); +} + +async function keepOrRelease( + context: FrontContext, + { request, identity, plan, grant, address }: Granted, + reply: Parameters[0], + reading: { readonly result: Record }, +): Promise { + if (!request.signal.aborted) { + context.map.add(plan.leaseId, { + address, + deadline: grant.lease.ttlDeadline, + identity, + ttlMs: grant.lease.ttlMs, + }); + return relay(reply); + } + const lease = reading.result["lease"]; + const named = + typeof lease === "object" && lease !== null ? (lease as Record) : {}; + const ended = await releaseAgentDevice(context, request, identity, plan.leaseId, scopeOf(named)); + if (ended.reading.kind === "success") await releaseSimlock(context, identity, plan.leaseId); + return undefined; +} + +async function settleNoReply( + context: FrontContext, + { request, identity, call, plan }: Granted, +): Promise { const ended = await releaseAgentDevice( context, request, @@ -298,7 +315,6 @@ export async function allocate( ended.reading.kind === "error" && (ended.reading.code === undefined || !LEASE_GONE.has(ended.reading.code)); if (!holds) await releaseSimlock(context, identity, plan.leaseId); - return frontReply(failure("agent-device did not answer the allocate"), call); } const LIST_TIMEOUT_MS = 5_000; diff --git a/src/agent-device/front/host-link.ts b/src/agent-device/front/host-link.ts index fddf36abe..3ada4efda 100644 --- a/src/agent-device/front/host-link.ts +++ b/src/agent-device/front/host-link.ts @@ -11,12 +11,10 @@ export interface HostLink { export class InProcessHostLink implements HostLink { constructor(private readonly host: AgentDeviceHost) {} - // fallow-ignore-next-line unused-class-member -- reached through the HostLink port by the front. status(): ReturnType { return this.host.status(); } - // fallow-ignore-next-line unused-class-member -- reached through the HostLink port by the front. forward(request: Request, identity: TokenIdentity): Promise { return this.host.forward(request, identity); } From 415d397164c6145b25b583c90f1257c13c7b5cd4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 01:51:58 +0200 Subject: [PATCH 06/10] refactor(agent-device): split the refusal check (#472) 1 failing -> 1 failing --- src/agent-device/front/allocate.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/agent-device/front/allocate.ts b/src/agent-device/front/allocate.ts index b6bc18f22..3d0108b47 100644 --- a/src/agent-device/front/allocate.ts +++ b/src/agent-device/front/allocate.ts @@ -44,13 +44,7 @@ function notPlain(params: Record): boolean { ); } -function refusalFor(params: Record): FrontReply | undefined { - const fields = leaseCallFields(params); - const ttl = fields.ttlMs; - if (leaseIdOf(fields.leaseId) === undefined) { - return refusal("refused: this agent-device is too old for simlock; upgrade it"); - } - if (notPlain(params)) return refusal("refused: simlock leases only plain devices"); +function deviceRefusal(fields: ReturnType): FrontReply | undefined { if (!present(fields.platform)) return refusal("refused: name --platform ios or android"); if (fields.platform !== "ios" && fields.platform !== "android") { return refusal("refused: only iOS and Android"); @@ -58,6 +52,18 @@ function refusalFor(params: Record): FrontReply | undefined { if (present(fields.udid) || present(fields.serial)) { return refusal("refused: simlock picks the device"); } + return undefined; +} + +function refusalFor(params: Record): FrontReply | undefined { + const fields = leaseCallFields(params); + const ttl = fields.ttlMs; + if (leaseIdOf(fields.leaseId) === undefined) { + return refusal("refused: this agent-device is too old for simlock; upgrade it"); + } + if (notPlain(params)) return refusal("refused: simlock leases only plain devices"); + const device = deviceRefusal(fields); + if (device !== undefined) return device; if (present(ttl) && !(typeof ttl === "number" && Number.isFinite(ttl) && ttl > 0)) { return refusal("refused: ttlMs must be a positive number of milliseconds"); } From 96bb2f82295f211fbbddf0158641ccb8455d7a41 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 07:09:06 +0200 Subject: [PATCH 07/10] feat(agent-device): cancel waiting allocates' lease requests on stop (#472) 1 failing -> 0 failing --- src/agent-device/edge.test.ts | 10 ++++++++-- src/agent-device/front/allocate.ts | 2 ++ src/agent-device/front/front-map.ts | 13 +++++++++++++ src/agent-device/index.ts | 8 ++++++++ src/daemon/main.ts | 4 ++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index 39f3c519d..4ae23d631 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -872,7 +872,7 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(await waiting(rig)).toEqual([]); }); - it("stopping the daemon while an allocate waits ends with no simlock lease and no lease request stored", async () => { + it("stopping the daemon while an allocate waits ends with no simlock lease, and the lease request it waited on is cancelled, not open, in state.json", async () => { const rig = await setUp({ maxRunning: 1 }); await granted(rig, 1, {}, { token: secrets.two }); const second = track(allocate(rig, 2)); @@ -884,7 +884,13 @@ describe("the agent-device front, through the whole worker daemon", () => { const state = await rig.filesystem.readFile(rig.statePath); expect(second.done()).toBe(true); expect(state).toContain(hex(1)); - expect(state).not.toContain(hex(2)); + const records = JSON.parse(state ?? "{}") as { + leaseRequests?: readonly { leaseId?: string; state: string }[]; + }; + expect(records.leaseRequests?.filter((record) => record.leaseId === hex(2))).toEqual([ + expect.objectContaining({ state: "cancelled" }), + ]); + expect(records.leaseRequests?.some((record) => record.state === "open")).toBe(false); }); it("an allocate still waiting 330 s after the front admitted it answers `timed out waiting for a device; nothing was leased`, and simlock's queue is empty", async () => { diff --git a/src/agent-device/front/allocate.ts b/src/agent-device/front/allocate.ts index 3d0108b47..d582746cb 100644 --- a/src/agent-device/front/allocate.ts +++ b/src/agent-device/front/allocate.ts @@ -156,6 +156,7 @@ async function requestLease( if (admitted) startCancel(); }; signal.addEventListener("abort", onAbort, { once: true }); + context.map.startWaiting(plan.leaseId); try { const grant = await context.dispatch( "lease.request", @@ -187,6 +188,7 @@ async function requestLease( }; } finally { settled = true; + context.map.stopWaiting(plan.leaseId); wakeCancel?.(); signal.removeEventListener("abort", onAbort); } diff --git a/src/agent-device/front/front-map.ts b/src/agent-device/front/front-map.ts index 2a5a7129a..b5dac6518 100644 --- a/src/agent-device/front/front-map.ts +++ b/src/agent-device/front/front-map.ts @@ -25,6 +25,19 @@ interface Held { export class FrontMap { readonly #held = new Map(); + readonly #waiting = new Set(); + + startWaiting(leaseId: string): void { + this.#waiting.add(leaseId); + } + + stopWaiting(leaseId: string): void { + this.#waiting.delete(leaseId); + } + + waitingIds(): readonly string[] { + return [...this.#waiting]; + } constructor(readonly clock: Clock) {} diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index 506cdb770..3b390114a 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -27,6 +27,14 @@ export function createAgentDeviceEdgeParts( return { link: new InProcessHostLink(new AgentDeviceHost(upstream)), map: new FrontMap(clock) }; } +// why: a stop refuses every dispatch, so waiting allocates' lease requests are cancelled through leasing directly, before the gateway stops; otherwise they stay open across a restart. +export async function cancelWaitingAllocates( + parts: AgentDeviceEdgeParts, + cancel: (requesterId: string) => Promise, +): Promise { + await Promise.allSettled(parts.map.waitingIds().map((id) => cancel(id))); +} + export function createAgentDeviceFront( parts: AgentDeviceEdgeParts, deps: { readonly dispatch: HttpDispatch; readonly clock: Clock; readonly logger: Logger }, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 7d1ecdc04..e59280425 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"; import { agentDeviceFindings, AgentDeviceSupervisor, + cancelWaitingAllocates, createAgentDeviceEdgeParts, createAgentDeviceFront, SupervisorAgentDeviceUpstream, @@ -518,6 +519,9 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { + if (agentDeviceParts !== undefined) { + await cancelWaitingAllocates(agentDeviceParts, (id) => leasing.cancelPending(id)); + } await gatewayStarted.catch(() => undefined); await stopHttpGateway?.(); // Stopped with the other auxiliary frontends, and for the same reason: the uplink is a From 9d78eb95a6774cbfe9916156217aacf254b1269a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 07:41:43 +0200 Subject: [PATCH 08/10] test: exempt the vendored agent-device tarball from the NUL check (#472) --- src/source-hygiene.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/source-hygiene.test.ts b/src/source-hygiene.test.ts index c5310fc52..2a2f52b3b 100644 --- a/src/source-hygiene.test.ts +++ b/src/source-hygiene.test.ts @@ -9,6 +9,10 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const NUL = 0x00; +/** why: built binaries committed for tests or vendored packages are never searched as text. */ +const isCommittedBinary = (name: string): boolean => + name.startsWith("e2e/fixtures/") || (name.startsWith("vendor/") && name.endsWith(".tgz")); + /** * why: a raw NUL byte in a text file makes `grep` and `ripgrep` classify the whole file as * binary and print `binary file matches` instead of the matching lines -- so the file @@ -17,8 +21,8 @@ const NUL = 0x00; * * Enumerated from `git ls-files` rather than a directory walk so the check sees every * tracked file in the repository (testing rule 4), not just the directories this test - * happened to name. The one exception is `e2e/fixtures/`: built binaries committed for tests, - * never searched as text, so they are skipped. Untracked and ignored files are out of scope: they are not part of + * happened to name. The exceptions are `e2e/fixtures/` and the vendored `vendor/*.tgz` tarballs: built binaries + * committed for tests, never searched as text, so they are skipped. Untracked and ignored files are out of scope: they are not part of * what anyone clones or searches. */ describe("tracked files", () => { @@ -30,7 +34,7 @@ describe("tracked files", () => { .toString("utf8") .split("\0") .filter((name) => name.length > 0) - .filter((name) => !name.startsWith("e2e/fixtures/")) + .filter((name) => !isCommittedBinary(name)) // A submodule or a path deleted from the working tree is still listed by // `git ls-files`; only regular files can be read. .filter((name) => { @@ -47,12 +51,15 @@ describe("tracked files", () => { expect(trackedFiles.length).toBeGreaterThan(100); }); - it("hold no path under e2e/fixtures/ and still hold e2e/appduct-fixtures.test.ts", () => { - expect(trackedFiles.filter((name) => name.startsWith("e2e/fixtures/"))).toEqual([]); + it("hold no committed binary and still hold e2e/appduct-fixtures.test.ts", () => { + expect(trackedFiles.filter(isCommittedBinary)).toEqual([]); + expect(trackedFiles.some((name) => name.startsWith("vendor/") && name.endsWith(".tgz"))).toBe( + false, + ); expect(trackedFiles).toContain("e2e/appduct-fixtures.test.ts"); }); - it("outside e2e/fixtures/ contain no raw NUL byte, so grep and ripgrep can search them", () => { + it("outside the committed binaries contain no raw NUL byte, so grep and ripgrep can search them", () => { const offenders = trackedFiles .map((name) => ({ name, contents: readFileSync(join(REPO_ROOT, name)) })) .filter(({ contents }) => contents.includes(NUL)) From 8a52f4a6e14bd9d363ab6cddadce4413bd30d4fa Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 08:05:40 +0200 Subject: [PATCH 09/10] fix(agent-device): release with the right scope, count waiting allocates, report a proxy that fails to load (#472) --- src/agent-device/edge.test.ts | 29 ++++- src/agent-device/front/allocate.test.ts | 155 +++++++++++++++++++++++ src/agent-device/front/allocate.ts | 21 ++- src/agent-device/front/front-map.test.ts | 49 +++++++ src/agent-device/front/front-map.ts | 10 +- src/agent-device/front/front.ts | 4 +- src/agent-device/front/host-link.ts | 5 +- src/agent-device/host/host.test.ts | 43 +++++++ src/agent-device/host/host.ts | 8 +- src/agent-device/index.ts | 2 +- src/agent-device/testing.ts | 19 ++- 11 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 src/agent-device/front/allocate.test.ts create mode 100644 src/agent-device/host/host.test.ts diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index 4ae23d631..f24f26917 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -490,7 +490,7 @@ describe("the agent-device front, through the whole worker daemon", () => { ]); }); - it("allocate `--platform ios` makes a simlock lease whose ID and requester are the CLI's lease ID, owned by the token's principal, and the lease request names no osVersion, mode, imageTag or physical device and offers no download", async () => { + it("allocate `--platform ios` makes a simlock lease whose ID and requester are the CLI's lease ID, owned by the token's principal, and the lease request names no osVersion, mode, imageTag or physical device", async () => { const rig = await setUp(); const answer = await allocate(rig, 1); @@ -807,7 +807,7 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(allocatesReceived(ready, 3)).toEqual([]); }); - it("a CLI that hangs up after device work started for it gets no agent-device lease: the grant that lands later is released at once and never reaches the agent-device daemon; when that device work fails and the request goes back to the queue, the front's repeated cancel removes it, and it is never granted", async () => { + it("a CLI that hangs up after device work started for it gets no agent-device lease: the grant that lands later is released at once and never reaches the agent-device daemon; when that device work fails and the request goes back to the queue, it is never granted", async () => { const slowDevice = (clock: FakeClock) => [ new FakeDriver({ ...phoneModels, @@ -974,9 +974,22 @@ describe("the agent-device front, through the whole worker daemon", () => { .not.toContain(hex(4)); }); + it("the follow-up release after an allocate with no usable reply carries the allocate's runId, leaseProvider and clientId and not its deviceKey", async () => { + const rig = await setUp(); + rig.fake.answerNextAllocateWith("bad gateway"); + + const answer = await envelopeOf(await allocate(rig, 1, { deviceKey: "saved-device" })); + + expect(answer.error?.data?.code).toBe("COMMAND_FAILED"); + const params = rig.fake.rpcRequests("agent_device.lease.release")[0]?.params; + expect(params).toMatchObject({ ...scope, leaseId: hex(1) }); + expect(params).not.toHaveProperty("deviceKey"); + }); + it("a CLI that hangs up while agent-device makes its lease leaves neither lease: the agent-device lease is released with the reply's scope, then the simlock lease; when that release fails, the simlock lease stays until its TTL", async () => { const rig = await setUp(); rig.fake.holdAllocate(); + rig.fake.nameDeviceKeyInNextReply("device-key-1"); const hangUp = new AbortController(); const first = track(allocate(rig, 1, {}, { signal: hangUp.signal })); await sleep(50); @@ -990,7 +1003,10 @@ describe("the agent-device front, through the whole worker daemon", () => { await expect.poll(async () => (await leases(rig)).length).toBe(0); expect(rig.fake.leases.has(hex(1))).toBe(false); - expect(rig.fake.rpcRequests("agent_device.lease.release")[0]?.params).toMatchObject(scope); + expect(rig.fake.rpcRequests("agent_device.lease.release")[0]?.params).toMatchObject({ + ...scope, + deviceKey: "device-key-1", + }); rig.fake.holdAllocate(); rig.fake.failNextRelease({ code: "COMMAND_FAILED", message: "release failed" }); @@ -1091,6 +1107,13 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(rig.fake.rpcRequests("agent_device.lease.release")).toHaveLength(1); expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); expect(rig.fake.leases.has(hex(1))).toBe(true); + + rig.fake.letAnyTenantRelease(); + const crossed = await envelopeOf(await release(rig, 1, { token: secrets.two })); + + expect(crossed.result).toMatchObject({ ok: true }); + expect(rig.fake.leases.has(hex(1))).toBe(false); + expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); }); it("a command's reply, a streamed progress body included, reaches the CLI unchanged, and a CLI that hangs up during it aborts the request the agent-device daemon was answering", async () => { diff --git a/src/agent-device/front/allocate.test.ts b/src/agent-device/front/allocate.test.ts new file mode 100644 index 000000000..7709fff9f --- /dev/null +++ b/src/agent-device/front/allocate.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HttpDispatch } from "../../http/dispatcher-session.js"; +import type { TokenIdentity } from "../../http/token-store.js"; +import { FakeClock, NoopLogger } from "../../ports/index.js"; +import { allocate } from "./allocate.js"; +import type { FrontContext } from "./context.js"; +import { FrontMap } from "./front-map.js"; + +const agent: TokenIdentity = { requesterId: "tok_a", role: "agent" }; +const leaseId = "00000000000000000000000000000001"; + +interface AdmitSession { + readonly onRequestAdmitted?: (id: string, replayed: boolean) => void; +} +type Handler = (input: Record, session: AdmitSession) => Promise; + +function setUp() { + const clock = new FakeClock(1_000); + const map = new FrontMap(clock); + const hangUp = new AbortController(); + const calls: { readonly operation: string; readonly input: Record }[] = []; + const handlers: Record = { + "lease.list": async () => ({ leases: [] }), + "lease.request": async () => { + throw new Error("no device"); + }, + }; + const dispatch = (async (operation: string, input: Record, session: never) => { + calls.push({ input, operation }); + const handler = handlers[operation]; + if (handler === undefined) throw new Error(`unexpected ${operation}`); + return handler(input, session); + }) as unknown as HttpDispatch; + const context = { + clock, + dispatch, + link: { forward: vi.fn(), status: vi.fn() }, + logger: new NoopLogger(), + map, + } as unknown as FrontContext; + const run = () => + allocate(context, { + call: { + body: {}, + id: 1, + kind: "request", + method: "agent_device.lease.allocate", + params: { leaseId, platform: "ios" }, + text: "", + }, + identity: agent, + leaseMaxTtlMs: 600_000, + request: new Request("http://front.test/rpc", { method: "POST", signal: hangUp.signal }), + }); + const requests = () => calls.filter((call) => call.operation === "lease.request"); + const cancels = () => calls.filter((call) => call.operation === "lease.cancel"); + return { cancels, clock, handlers, hangUp, map, requests, run }; +} + +// why: the request must stay open until the test admits and settles it, to hang up in between. +function parkedRequest(rig: ReturnType) { + let admit!: () => void; + let settle!: () => void; + rig.handlers["lease.request"] = (_input, session) => + new Promise((_resolve, reject) => { + admit = () => session.onRequestAdmitted?.("req_1", false); + settle = () => reject(new Error("cancelled")); + }); + return { admit: () => admit(), settle: () => settle() }; +} + +describe("the allocate front", () => { + it("offers simlock no download: the lease request names no allowDownload", async () => { + const rig = setUp(); + + await rig.run(); + + expect(rig.requests()).toHaveLength(1); + expect(rig.requests()[0]?.input).not.toHaveProperty("allowDownload"); + }); + + it("cancels the lease request when the CLI hangs up before the daemon admits it, as soon as the daemon does", async () => { + const rig = setUp(); + const parked = parkedRequest(rig); + const done = rig.run(); + await vi.waitFor(() => expect(rig.requests()).toHaveLength(1)); + + rig.hangUp.abort(); + expect(rig.cancels()).toEqual([]); + parked.admit(); + + await vi.waitFor(() => expect(rig.cancels()).toHaveLength(1)); + expect(rig.cancels()[0]?.input).toEqual({ requesterId: leaseId }); + parked.settle(); + await done; + }); + + it("repeats the cancel every second while the request is not cancellable, and stops once it is", async () => { + const rig = setUp(); + const parked = parkedRequest(rig); + const answers = ["not-cancellable", "not-cancellable", "cancelled"]; + rig.handlers["lease.cancel"] = async () => ({ result: answers.shift() }); + const done = rig.run(); + await vi.waitFor(() => expect(rig.requests()).toHaveLength(1)); + parked.admit(); + + rig.hangUp.abort(); + await vi.waitFor(() => expect(rig.cancels()).toHaveLength(1)); + await vi.waitFor(() => expect(rig.clock.pendingTimerCount).toBe(1)); + rig.clock.advance(999); + expect(rig.cancels()).toHaveLength(1); + rig.clock.advance(1); + await vi.waitFor(() => expect(rig.cancels()).toHaveLength(2)); + await vi.waitFor(() => expect(rig.clock.pendingTimerCount).toBe(1)); + rig.clock.advance(1_000); + await vi.waitFor(() => expect(rig.cancels()).toHaveLength(3)); + + expect(rig.clock.pendingTimerCount).toBe(0); + parked.settle(); + await done; + expect(rig.cancels()).toHaveLength(3); + }); + + it("refreshes the front map at each allocate", async () => { + const rig = setUp(); + const refresh = vi.spyOn(rig.map, "refresh"); + + await rig.run(); + await rig.run(); + + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("gives up on a lease.list that does not answer within 5 s and goes on to the lease request", async () => { + const rig = setUp(); + rig.map.add("old", { address: "a", deadline: 500, identity: agent, ttlMs: 60_000 }); + let lists = 0; + rig.handlers["lease.list"] = async () => { + lists += 1; + return lists === 1 ? new Promise(() => undefined) : { leases: [] }; + }; + const done = rig.run(); + await vi.waitFor(() => expect(lists).toBe(1)); + + rig.clock.advance(4_999); + await Promise.resolve(); + expect(rig.requests()).toEqual([]); + rig.clock.advance(1); + await done; + + expect(rig.requests()).toHaveLength(1); + expect(rig.map.get("old")?.endedAt).toBeUndefined(); + }); +}); diff --git a/src/agent-device/front/allocate.ts b/src/agent-device/front/allocate.ts index d582746cb..3b44469df 100644 --- a/src/agent-device/front/allocate.ts +++ b/src/agent-device/front/allocate.ts @@ -20,7 +20,9 @@ const CLASS_OF_TARGET: Record = { mobile: "phone", tv: "tv", }; -const SCOPE_KEYS = ["runId", "leaseProvider", "clientId", "deviceKey"] as const; +const CALL_SCOPE_KEYS = ["runId", "leaseProvider", "clientId"] as const; +// why: only agent-device's reply names the device key it gave the lease, since the front stripped the CLI's key from the allocate it forwarded. +const REPLY_SCOPE_KEYS = [...CALL_SCOPE_KEYS, "deviceKey"] as const; const present = (value: unknown): boolean => value !== undefined && value !== null; @@ -194,9 +196,12 @@ async function requestLease( } } -function scopeOf(source: Record | undefined): Record { +function scopeOf( + source: Record | undefined, + keys: readonly string[], +): Record { const scope: Record = {}; - for (const key of SCOPE_KEYS) { + for (const key of keys) { if (source?.[key] !== undefined) scope[key] = source[key]; } return scope; @@ -303,7 +308,13 @@ async function keepOrRelease( const lease = reading.result["lease"]; const named = typeof lease === "object" && lease !== null ? (lease as Record) : {}; - const ended = await releaseAgentDevice(context, request, identity, plan.leaseId, scopeOf(named)); + const ended = await releaseAgentDevice( + context, + request, + identity, + plan.leaseId, + scopeOf(named, REPLY_SCOPE_KEYS), + ); if (ended.reading.kind === "success") await releaseSimlock(context, identity, plan.leaseId); return undefined; } @@ -317,7 +328,7 @@ async function settleNoReply( request, identity, plan.leaseId, - scopeOf(call.params), + scopeOf(call.params, CALL_SCOPE_KEYS), ); const holds = ended.reading.kind === "error" && diff --git a/src/agent-device/front/front-map.test.ts b/src/agent-device/front/front-map.test.ts index 4fde460b2..dad3c4f7d 100644 --- a/src/agent-device/front/front-map.test.ts +++ b/src/agent-device/front/front-map.test.ts @@ -47,4 +47,53 @@ describe("the front map", () => { expect(map.get("fresh")).toMatchObject({ deadline: 9_000_000 }); expect(map.get("fresh")?.endedAt).toBeUndefined(); }); + + it("keeps a lease ID in the waiting list while any allocate with it still waits", () => { + const map = new FrontMap(new FakeClock(1_000)); + map.startWaiting("lease-a"); + map.startWaiting("lease-a"); + map.startWaiting("lease-b"); + + map.stopWaiting("lease-a"); + expect(map.waitingIds()).toEqual(["lease-a", "lease-b"]); + + map.stopWaiting("lease-a"); + map.stopWaiting("lease-b"); + expect(map.waitingIds()).toEqual([]); + }); + + it("leaves every entry unchanged when lease.list fails for its principal", async () => { + const clock = new FakeClock(1_000); + const map = new FrontMap(clock); + map.add("lease-a", entry(2_000)); + clock.advance(10_000); + + await map.refresh(async () => { + throw new Error("lease.list refused"); + }); + + expect(map.get("lease-a")).toMatchObject({ deadline: 2_000 }); + expect(map.get("lease-a")?.endedAt).toBeUndefined(); + }); + + it("asks lease.list for at most 100 principals, the ones holding the oldest deadlines", async () => { + const clock = new FakeClock(1_000); + const map = new FrontMap(clock); + for (let n = 0; n < 101; n += 1) { + const identity: TokenIdentity = { requesterId: `tok_${n}`, role: "agent" }; + map.add(`lease-${n}`, { address: "a", deadline: 2_000 + n, identity, ttlMs }); + } + clock.advance(10_000); + const asked: string[] = []; + + await map.refresh(async (who) => { + asked.push(who.requesterId); + return []; + }); + + expect(asked).toHaveLength(100); + expect(asked).not.toContain("tok_100"); + expect(map.get("lease-0")?.endedAt).toBeDefined(); + expect(map.get("lease-100")?.endedAt).toBeUndefined(); + }); }); diff --git a/src/agent-device/front/front-map.ts b/src/agent-device/front/front-map.ts index b5dac6518..3cfdb4d05 100644 --- a/src/agent-device/front/front-map.ts +++ b/src/agent-device/front/front-map.ts @@ -25,18 +25,20 @@ interface Held { export class FrontMap { readonly #held = new Map(); - readonly #waiting = new Set(); + readonly #waiting = new Map(); startWaiting(leaseId: string): void { - this.#waiting.add(leaseId); + this.#waiting.set(leaseId, (this.#waiting.get(leaseId) ?? 0) + 1); } stopWaiting(leaseId: string): void { - this.#waiting.delete(leaseId); + const left = (this.#waiting.get(leaseId) ?? 0) - 1; + if (left > 0) this.#waiting.set(leaseId, left); + else this.#waiting.delete(leaseId); } waitingIds(): readonly string[] { - return [...this.#waiting]; + return [...this.#waiting.keys()]; } constructor(readonly clock: Clock) {} diff --git a/src/agent-device/front/front.ts b/src/agent-device/front/front.ts index 095709d52..290305673 100644 --- a/src/agent-device/front/front.ts +++ b/src/agent-device/front/front.ts @@ -45,7 +45,7 @@ async function handleRpc( if (read.kind === "malformed") { return frontReply(refusal("refused: send one JSON-RPC request"), { id: null }); } - const status = context.link.status(); + const status = await context.link.status(); if (status.state === "unavailable") return frontReply(notAvailable(status.reason), read); const kind = classifyCall(read.method, read.params); if (kind === "wrapped-lease") { @@ -92,7 +92,7 @@ export function createFront(context: FrontContext): HttpMount { return frontReply(refusal(AGENT_TOKEN_ONLY, 403), isRpc ? { id: null } : undefined); } if (isRpc) return handleRpc(context, request, identity); - const status = context.link.status(); + const status = await context.link.status(); if (status.state === "unavailable") return frontReply(notAvailable(status.reason), undefined); return passThrough(context, request, identity, undefined); }, diff --git a/src/agent-device/front/host-link.ts b/src/agent-device/front/host-link.ts index 3ada4efda..9dac488f1 100644 --- a/src/agent-device/front/host-link.ts +++ b/src/agent-device/front/host-link.ts @@ -2,9 +2,10 @@ import type { TokenIdentity } from "../../http/token-store.js"; import type { AgentDeviceHost } from "../host/host.js"; export interface HostLink { - status(): + status(): Promise< | { readonly state: "running"; readonly leaseMaxTtlMs: number } - | { readonly state: "unavailable"; readonly reason: string }; + | { readonly state: "unavailable"; readonly reason: string } + >; forward(request: Request, identity: TokenIdentity): Promise; } diff --git a/src/agent-device/host/host.test.ts b/src/agent-device/host/host.test.ts new file mode 100644 index 000000000..a3497f9c5 --- /dev/null +++ b/src/agent-device/host/host.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AgentDeviceHost } from "./host.js"; +import type { AgentDeviceUpstream } from "./upstream.js"; + +vi.mock("@agent-device/proxy", () => { + throw new Error("module not found"); +}); + +const running = { + baseUrl: "http://127.0.0.1:47001", + generation: 1, + leaseMaxTtlMs: 600_000, + state: "running", + token: "daemon-token", +} as const; + +describe("the agent-device host", () => { + it("answers unavailable with `could not load @agent-device/proxy` when the proxy package fails to load, before any allocate is served", async () => { + const upstream = { + current: () => running, + fetch: async () => new Response(), + } as unknown as AgentDeviceUpstream; + const host = new AgentDeviceHost(upstream); + + expect(await host.status()).toEqual({ + reason: expect.stringMatching(/^could not load @agent-device\/proxy: ./), + state: "unavailable", + }); + }); + + it("passes the upstream's own reason through while the daemon is unavailable", async () => { + const upstream = { + current: () => ({ reason: "agent-device is stopped", state: "unavailable" }), + fetch: async () => new Response(), + } as unknown as AgentDeviceUpstream; + + expect(await new AgentDeviceHost(upstream).status()).toEqual({ + reason: "agent-device is stopped", + state: "unavailable", + }); + }); +}); diff --git a/src/agent-device/host/host.ts b/src/agent-device/host/host.ts index de605ed2c..77841d7f6 100644 --- a/src/agent-device/host/host.ts +++ b/src/agent-device/host/host.ts @@ -30,11 +30,11 @@ export class AgentDeviceHost { constructor(readonly upstream: AgentDeviceUpstream) {} - status(): HostStatus { - if (this.#loadError !== undefined) return { reason: this.#loadError, state: "unavailable" }; + async status(): Promise { const current = this.upstream.current(); if (current.state === "unavailable") return current; - void this.#load(); + await this.#load(); + if (this.#loadError !== undefined) return { reason: this.#loadError, state: "unavailable" }; return { leaseMaxTtlMs: current.leaseMaxTtlMs, state: "running" }; } @@ -52,7 +52,7 @@ export class AgentDeviceHost { } async forward(request: Request, identity: TokenIdentity): Promise { - const status = this.status(); + const status = await this.status(); if (status.state === "unavailable") throw new Error(status.reason); const module = await this.#load(); const current = this.upstream.current(); diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index 3b390114a..f4be9891b 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -9,7 +9,7 @@ import type { AgentDeviceUpstream } from "./host/upstream.js"; export { agentDeviceFindings } from "./host/availability-findings.js"; export { AgentDeviceSupervisor } from "./host/agent-device-supervisor.js"; -// fallow-ignore-next-line unused-type -- the availability contract the front (#472) and doctor (#471) read. +// fallow-ignore-next-line unused-type -- the shape `AgentDeviceSupervisor.availability()` returns, which `agentDeviceFindings` reads. export type { AgentDeviceAvailability } from "./host/agent-device-supervisor.js"; export type { AgentDeviceUpstream } from "./host/upstream.js"; export { SupervisorAgentDeviceUpstream } from "./host/upstream.js"; diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index 18b3daaed..ffa74dd08 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -103,6 +103,8 @@ export class FakeAgentDevice implements AgentDeviceUpstream { #failAllocate: FakeFailure | undefined; #failRelease: FakeFailure | undefined; #allocateBody: string | undefined; + #replyDeviceKey: string | undefined; + #anyTenantReleases = false; #allocateGate: Gate | undefined; #commandGate: Gate | undefined; #streamChunks: readonly string[] | undefined; @@ -146,6 +148,15 @@ export class FakeAgentDevice implements AgentDeviceUpstream { this.#allocateBody = body; } + nameDeviceKeyInNextReply(deviceKey: string): void { + this.#replyDeviceKey = deviceKey; + } + + // why: a daemon that finds a lease whichever tenant names it leaves only the front's own owner check to stop a cross-tenant release. + letAnyTenantRelease(): void { + this.#anyTenantReleases = true; + } + holdAllocate(): void { this.#allocateGate = gate(); } @@ -239,10 +250,11 @@ export class FakeAgentDevice implements AgentDeviceUpstream { const leaseId = String(params["leaseId"]); const scope = { clientId: params["clientId"], - deviceKey: params["deviceKey"], + deviceKey: this.#replyDeviceKey ?? params["deviceKey"], leaseProvider: params["leaseProvider"], runId: params["runId"], }; + this.#replyDeviceKey = undefined; this.leases.set(leaseId, { ended: false, leaseId, scope, tenant, ttlMs: params["ttlMs"] }); return envelope(id, { lease: { ...scope, leaseId, tenantId: tenant, ttlMs: params["ttlMs"] }, @@ -255,13 +267,14 @@ export class FakeAgentDevice implements AgentDeviceUpstream { this.#failRelease = undefined; if (failure !== undefined) return errorEnvelope(id, failure); const lease = this.leases.get(String(params["leaseId"])); - if (lease === undefined || lease.tenant !== tenant) { + if (lease === undefined || (!this.#anyTenantReleases && lease.tenant !== tenant)) { return errorEnvelope(id, { code: "LEASE_NOT_FOUND", message: "lease not found" }); } if ( lease.scope.runId !== params["runId"] || lease.scope.leaseProvider !== params["leaseProvider"] || - lease.scope.clientId !== params["clientId"] + lease.scope.clientId !== params["clientId"] || + lease.scope.deviceKey !== params["deviceKey"] ) { return errorEnvelope(id, { code: "UNAUTHORIZED", message: "lease scope mismatch" }); } From c1bc8a5d900e3614b4b0a35bcdf97c124ad44b58 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Oct 2026 08:11:16 +0200 Subject: [PATCH 10/10] test(agent-device): make the oldest-deadline and 5 s lease.list tests able to fail; fix the cross-tenant comment (#472) --- src/agent-device/front/allocate.test.ts | 3 ++- src/agent-device/front/front-map.test.ts | 9 +++++---- src/agent-device/testing.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/agent-device/front/allocate.test.ts b/src/agent-device/front/allocate.test.ts index 7709fff9f..1a02cc2a9 100644 --- a/src/agent-device/front/allocate.test.ts +++ b/src/agent-device/front/allocate.test.ts @@ -143,8 +143,9 @@ describe("the allocate front", () => { const done = rig.run(); await vi.waitFor(() => expect(lists).toBe(1)); + await new Promise((resolve) => setImmediate(resolve)); rig.clock.advance(4_999); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); expect(rig.requests()).toEqual([]); rig.clock.advance(1); await done; diff --git a/src/agent-device/front/front-map.test.ts b/src/agent-device/front/front-map.test.ts index dad3c4f7d..e06388851 100644 --- a/src/agent-device/front/front-map.test.ts +++ b/src/agent-device/front/front-map.test.ts @@ -81,7 +81,7 @@ describe("the front map", () => { const map = new FrontMap(clock); for (let n = 0; n < 101; n += 1) { const identity: TokenIdentity = { requesterId: `tok_${n}`, role: "agent" }; - map.add(`lease-${n}`, { address: "a", deadline: 2_000 + n, identity, ttlMs }); + map.add(`lease-${n}`, { address: "a", deadline: 2_100 - n, identity, ttlMs }); } clock.advance(10_000); const asked: string[] = []; @@ -92,8 +92,9 @@ describe("the front map", () => { }); expect(asked).toHaveLength(100); - expect(asked).not.toContain("tok_100"); - expect(map.get("lease-0")?.endedAt).toBeDefined(); - expect(map.get("lease-100")?.endedAt).toBeUndefined(); + expect(asked).toContain("tok_100"); + expect(asked).not.toContain("tok_0"); + expect(map.get("lease-100")?.endedAt).toBeDefined(); + expect(map.get("lease-0")?.endedAt).toBeUndefined(); }); }); diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index ffa74dd08..d5f31a593 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -152,7 +152,7 @@ export class FakeAgentDevice implements AgentDeviceUpstream { this.#replyDeviceKey = deviceKey; } - // why: a daemon that finds a lease whichever tenant names it leaves only the front's own owner check to stop a cross-tenant release. + // why: a daemon that finds a lease whichever tenant names it lets a cross-tenant release through to agent-device; only the simlock release is guarded by the front's owner check. letAnyTenantRelease(): void { this.#anyTenantReleases = true; }