From e9b607473837534c7b36ee41dce428f4f82feec6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:49:32 -0700 Subject: [PATCH 1/3] fix(resources): point tenant isolation lease errors to leases.allocate The thrown message is now "tenant isolation requires lease id from leases.allocate.", and the admission tests match that wording. The Limrun page adds a provider that calls leases.allocate in acquire, returns lease.leaseId with sessionIsolation set to tenant, and calls leases.release with the same id. The note states that the daemon is started with the instance URL and token, and that neither call creates or deletes the instance. Fixes #3350 --- .../src/resources/lease-registry-scope.ts | 4 ++- .../__tests__/request-admission.test.ts | 8 ++--- website/docs/docs/limrun.md | 29 +++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/daemon/src/resources/lease-registry-scope.ts b/packages/daemon/src/resources/lease-registry-scope.ts index aa643b9a39..4b65255f7b 100644 --- a/packages/daemon/src/resources/lease-registry-scope.ts +++ b/packages/daemon/src/resources/lease-registry-scope.ts @@ -256,7 +256,9 @@ 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 from leases.allocate.'); + } 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..9d471f096d 100644 --- a/packages/daemon/src/sessions/__tests__/request-admission.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-admission.test.ts @@ -61,7 +61,7 @@ test('close on a lease-less but stored tenant-isolated session still requires a registry, session, ), - /tenant isolation requires lease id/, + /tenant isolation requires lease id from leases\.allocate/, ); }); @@ -84,7 +84,7 @@ test('close with an app target still requires a lease id even with no lease anyw registry, undefined, ), - /tenant isolation requires lease id/, + /tenant isolation requires lease id from leases\.allocate/, ); }); @@ -123,7 +123,7 @@ test('non-close commands on a tenant-isolated session still require a lease id', registry, undefined, ), - /tenant isolation requires lease id/, + /tenant isolation requires lease id from leases\.allocate/, ); }); @@ -143,7 +143,7 @@ test.each(['bogus', 'proxy', 'browserstack'])( registry, undefined, ), - /tenant isolation requires lease id/, + /tenant isolation requires lease id from leases\.allocate/, ); }, ); diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index b4cf15c391..66d2e27e98 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -97,6 +97,35 @@ 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. + +```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', + }); + 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 From d42f9b553bc5d23da1726b67fc412a2cb80e5c36 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:38:31 -0700 Subject: [PATCH 2/3] docs(limrun): allocate the provider lease with retainOnClose The provider example releases its lease itself, so allocate it with retainOnClose: true. Without it, a session close before release ends the lease and leases.release returns released: false. Also note that a retained lease still expires after its ttlMs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFTE4BHbGYzgf8Uhzcppdh --- website/docs/docs/limrun.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 66d2e27e98..83d66d117d 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -97,7 +97,7 @@ 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. +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'; @@ -114,6 +114,7 @@ const provider = { const lease = await createAgentDeviceClient({ session: `lease-${req.slot}` }).leases.allocate({ ...scope, platform: 'ios', + retainOnClose: true, }); return { id: lease.leaseId, From 2da53a0bb26c16748b3bf72f8fdfd98fa0ce6327 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:38:55 -0700 Subject: [PATCH 3/3] fix(resources): carry the leases.allocate pointer in the error hint Keeps the tenant isolation message as "tenant isolation requires lease id." and moves the remediation into the AppError hint, "Allocate a lease with leases.allocate and pass its leaseId.", the field normalizeError already keeps. The request-admission tests now assert the message and the hint separately. --- .../src/resources/lease-registry-scope.ts | 4 +++- .../__tests__/request-admission.test.ts | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/daemon/src/resources/lease-registry-scope.ts b/packages/daemon/src/resources/lease-registry-scope.ts index 4b65255f7b..c158253c3c 100644 --- a/packages/daemon/src/resources/lease-registry-scope.ts +++ b/packages/daemon/src/resources/lease-registry-scope.ts @@ -257,7 +257,9 @@ export function normalizeLeaseAdmissionRequest( 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 from leases.allocate.'); + 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 }; } diff --git a/packages/daemon/src/sessions/__tests__/request-admission.test.ts b/packages/daemon/src/sessions/__tests__/request-admission.test.ts index 9d471f096d..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 from leases\.allocate/, + 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 from leases\.allocate/, + 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 from leases\.allocate/, + missingLeaseIdError, ); }); @@ -143,7 +153,7 @@ test.each(['bogus', 'proxy', 'browserstack'])( registry, undefined, ), - /tenant isolation requires lease id from leases\.allocate/, + missingLeaseIdError, ); }, );