diff --git a/packages/daemon/src/resources/lease-registry-scope.ts b/packages/daemon/src/resources/lease-registry-scope.ts index aa643b9a39..c158253c3c 100644 --- a/packages/daemon/src/resources/lease-registry-scope.ts +++ b/packages/daemon/src/resources/lease-registry-scope.ts @@ -256,7 +256,11 @@ export function normalizeLeaseAdmissionRequest( const runId = normalizeRunId(request.runId); if (!runId) throw new AppError('INVALID_ARGS', 'tenant isolation requires run id.'); const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.'); + if (!leaseId) { + throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.', { + hint: 'Allocate a lease with leases.allocate and pass its leaseId.', + }); + } return { ...request, tenantId, runId, leaseId, leaseBackend }; } function normalizeOptionalLeaseScope( diff --git a/packages/daemon/src/sessions/__tests__/request-admission.test.ts b/packages/daemon/src/sessions/__tests__/request-admission.test.ts index 0b92d7116b..5324a14483 100644 --- a/packages/daemon/src/sessions/__tests__/request-admission.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-admission.test.ts @@ -2,11 +2,21 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { makeIosSession, makeMacOsSession } from '../../__tests__/support/session-factories.ts'; import { parseDaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy-file'; +import { AppError } from '@agent-device/kernel/errors'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { assertRequestLeaseAdmission } from '../request-admission.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import type { ProviderAppCatalog } from '@agent-device/contracts/device'; +// The message names the missing field; the remediation pointer lives in the +// AppError hint, which normalizeError keeps for callers. +function missingLeaseIdError(error: unknown): boolean { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'tenant isolation requires lease id.'); + assert.equal(error.details?.hint, 'Allocate a lease with leases.allocate and pass its leaseId.'); + return true; +} + const limrunAppCatalog: ProviderAppCatalog = { supports: (provider) => provider === 'limrun', list: async () => [], @@ -61,7 +71,7 @@ test('close on a lease-less but stored tenant-isolated session still requires a registry, session, ), - /tenant isolation requires lease id/, + missingLeaseIdError, ); }); @@ -84,7 +94,7 @@ test('close with an app target still requires a lease id even with no lease anyw registry, undefined, ), - /tenant isolation requires lease id/, + missingLeaseIdError, ); }); @@ -123,7 +133,7 @@ test('non-close commands on a tenant-isolated session still require a lease id', registry, undefined, ), - /tenant isolation requires lease id/, + missingLeaseIdError, ); }); @@ -143,7 +153,7 @@ test.each(['bogus', 'proxy', 'browserstack'])( registry, undefined, ), - /tenant isolation requires lease id/, + missingLeaseIdError, ); }, ); diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index ebf13c8f16..367cab6d20 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -97,6 +97,36 @@ const runtime = new LimrunRuntime({ To drive existing instances, pass `instances: { ios: { apiUrl, token } }` (Android also takes `adbUrl`), with or without `apiKey`. The runtime never creates or deletes instances for a platform listed there. +A provider that reaches a harness-owned instance through the Node.js client allocates a Limrun lease in `acquire` and releases that same lease in `release`. Put the returned `lease.leaseId` on the client scope, and pass that same id to `leases.release`. Start the daemon with the instance URL and token (`LIM_IOS_INSTANCE_URL` and `LIM_IOS_INSTANCE_TOKEN`). `leases.allocate` attaches to that instance. `leases.release` returns `{ released, provider }`. Neither call creates or deletes the instance. Allocate with `retainOnClose: true` because the provider releases the lease itself: without it, a session `close` before `release` ends the lease and `leases.release` returns `released: false`. A retained lease still expires after its `ttlMs`, so keep heartbeating it between sessions. + +```ts +import { createAgentDeviceClient } from 'agent-device'; + +const scope = { + tenant: 'limrun', + runId: process.env.GITHUB_RUN_ID ?? 'local-run', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', +} as const; + +const provider = { + acquire: async (req: { slot: string }) => { + const lease = await createAgentDeviceClient({ session: `lease-${req.slot}` }).leases.allocate({ + ...scope, + platform: 'ios', + retainOnClose: true, + }); + return { + id: lease.leaseId, + client: { ...scope, leaseId: lease.leaseId, sessionIsolation: 'tenant' as const }, + }; + }, + release: async (lease: { id: string }) => { + await createAgentDeviceClient({}).leases.release({ ...scope, leaseId: lease.id }); + }, +}; +``` + After allocating a lease, call `runtime.getDeviceSession(device)` to get the allocated device's capabilities: app inventory, foreground state where Limrun exposes it, key input, bounded log reads, recording, remote asset installation, and the interactor. On Android it also exposes agent-device's `AndroidAdbProvider` for helpers and reversible port forwarding. On iOS it exposes a typed `simctl` execution handle so your bridge can own runner lifecycle and launch policy. The runtime does not expose raw Limrun clients. ## Get artifacts and troubleshoot