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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Thus, a handler that ignores its cancellation cannot hold a rented device open.

The inactivity TTL that the client asked for determines which leases this preservation reaches.
The daemon default is one minute.
A cloud WebDriver connection profile asks for ten minutes.
A remote device connection profile asks for five minutes.
Thus, a single command that runs longer than its own lease is ordinary on the default.
On the longer window, such a command is only reachable through a profile.

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ export function findMissingProxyLeaseFields(scope: LeaseScope): string[] {
* yet must assume this one, or it can be planning for a window the daemon would never grant.
*/
export const MIN_LEASE_WINDOW_MS = 5_000;
/** The idle window of every remote device lease: proxy, cloud device and cloud WebDriver. */
export const REMOTE_LEASE_IDLE_TTL_MS = 5 * 60 * 1000;

/**
* Why a lease stopped being ours: it is gone, spent, or taken back.
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/provider-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ export type ConnectionProviderCapabilities = {
supportsDeferredAppSelection: boolean;
supportsDirectPortReverse: boolean;
usesCloudWebDriverLease: boolean;
/**
* The provider leases a cloud-hosted device that bills while held. Its lease requests the same
* idle window as a proxy or cloud WebDriver lease on each lease-using command, not the daemon
* default.
*/
holdsCloudDevice?: boolean;
};
3 changes: 3 additions & 0 deletions packages/plugins/src/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ test('connection metadata admits local providers and rejects malformed or remote
return readPluginManifest(directory);
};
assert.deepEqual(read(connection).agentDevicePlugin.connection, connection);
const cloud = { ...connection, holdsCloudDevice: true };
assert.deepEqual(read(cloud).agentDevicePlugin.connection, cloud);
for (const invalid of [
null,
{},
{ ...connection, leaseKind: 'remote-daemon' },
{ ...connection, requiresRemoteDaemon: true },
{ ...connection, supportsArtifacts: 'true' },
{ ...connection, supportsDeferredAppSelection: undefined },
{ ...connection, holdsCloudDevice: 'false' },
]) {
assert.throws(() => read(invalid), { code: 'INVALID_ARGS' });
}
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function assertLocalConnectionCapabilities(
'supportsDirectPortReverse',
'usesCloudWebDriverLease',
].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') ||
(policy.holdsCloudDevice !== undefined && typeof policy.holdsCloudDevice !== 'boolean') ||
policy.requiresRemoteDaemon
) {
throw invalidManifest('Plugin connection must declare local provider capabilities');
Expand Down
1 change: 1 addition & 0 deletions packages/provider-limrun/src/runtime-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ test('allocation installs an exact uploaded asset before binding its application
},
},
spec: {
inactivityTimeout: '5m',
initialAssets: [
{
kind: 'App',
Expand Down
23 changes: 23 additions & 0 deletions packages/provider-limrun/src/session-allocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ test('the iOS instance create asks the SDK for one attempt, so a lost answer ren
assert.deepEqual(rented, []);
});

test.each([
['Android', allocateLimrunAndroidSession, 'POST /v1/android_instances'],
['iOS', allocateLimrunIosSession, 'POST /v1/ios_instances'],
] as const)(
'the %s instance create asks for a five minute inactivity timeout',
async (_platform, allocate, route) => {
const bodies: string[] = [];
const server = await startInstanceServer(async (request) => {
if (`${String(request.method)} ${request.path}` === route) bodies.push(request.body);
request.drop();
});
try {
await assert.rejects(async () => await allocate(allocationParams(server), () => {}));
} finally {
await server.close();
}
const spec = (JSON.parse(bodies[0] ?? '{}') as { spec?: { inactivityTimeout?: string } }).spec;
assert.equal(spec?.inactivityTimeout, '5m');
},
);

test('an Android instance that answers without its endpoints is reported as rented and left for its owner', async () => {
const routes: string[] = [];
const server = await startInstanceServer(async (request) => {
Expand Down Expand Up @@ -177,6 +198,7 @@ type InstanceServer = Readonly<{
type ReceivedRequest = Readonly<{
method: string | undefined;
path: string;
body: string;
/** Ends the connection without an answer, the ambiguity a retry must not resolve by POSTing again. */
drop(): void;
respond(status: number, body: string): Promise<void>;
Expand All @@ -193,6 +215,7 @@ function startInstanceServer(
method: req.method,
// The query is Limrun's own create parameters, not part of the route a retry would repeat.
path: (req.url ?? '').split('?')[0] ?? '',
body: Buffer.concat(chunks).toString('utf8'),
drop: () => req.socket.destroy(),
respond: async (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
Expand Down
6 changes: 6 additions & 0 deletions packages/provider-limrun/src/session-allocation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type Limrun from '@limrun/api';
import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device';
import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope';
import { AppError } from '@agent-device/kernel/errors';
import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields';
import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts';
Expand All @@ -15,6 +16,9 @@
import { createLimrunDeviceSession } from './device-session.ts';
import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts';

/** A created instance outlives its lease's idle window, so Limrun's 3 minute default never ends it first. */
Comment thread
thymikee marked this conversation as resolved.
Resolved
const LIMRUN_INACTIVITY_TIMEOUT = `${REMOTE_LEASE_IDLE_TTL_MS / 60_000}m`;

Check warning on line 20 in packages/provider-limrun/src/session-allocation.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: Created Limrun instances no longer outlive their lease: the inactivity timeout is set equal to it, not above it

The comment on session-allocation.ts:19 claims "A created instance outlives its lease's idle window, so Limrun's 3 minute default never ends it first", but LIMRUN\_INACTIVITY\_TIMEOUT is derived as exactly REMOTE\_LEASE\_IDLE\_TTL\_MS (`5m`), so the instance and the lease get the same window and race instead of one outliving the other. The two clocks do not start together: the instance exists from the moment Limrun names it (session-allocation.ts:102/150, i.e. mid-allocation), while ADR 0007 states the lease's inactivity TTL starts "from the moment allocation completed" and is renewed from the moment admitted work ends. Worse, `LIMRUN_KEEP_ALIVE` (runtime.ts:626-636 pings the instance every 30s) keeps the instance alive but never renews the lease, so with keep-alive on, the instance's clock is refreshed while the lease's is not: any idle gap over five minutes expires the lease, the next command allocates a fresh lease, and the runtime attaches to or creates an instance under that new lease — the long-lived kept-alive instance is orphaned and still billed. The old 3-minute value guaranteed the instance died before its lease, which was the safe direction. Either request a window strictly longer than the lease (e.g. lease TTL plus a margin) and fix the comment, or state in the docs that keep-alive keeps a billing instance with no lease. ### Also raised here Also raised at this location by maintainability: Comment on LIMRUN\_INACTIVITY\_TIMEOUT says the instance outlives the lease; the value makes them equal — packages/provider-limrun/src/session-allocation.ts:19 says "A created instance outlives its lease's idle window", but the value it introduces is derived from that same window, so the two expire after an identical five minutes — and they time from different events (Limrun sees API traffic; the daemon sees lease-using commands), so the instance can be reaped slightly before the lease. website/docs/docs/limrun.md states the accurate version ("the same window as th…

type LimrunInstance = {
metadata: { id: string };
status: {
Expand Down Expand Up @@ -75,6 +79,7 @@
wait: true,
metadata: params.metadata,
spec: {
inactivityTimeout: LIMRUN_INACTIVITY_TIMEOUT,
...(params.region ? { region: params.region } : {}),
...(params.app
? {
Expand Down Expand Up @@ -123,6 +128,7 @@
wait: true,
metadata: params.metadata,
spec: {
inactivityTimeout: LIMRUN_INACTIVITY_TIMEOUT,
...(params.region ? { region: params.region } : {}),
...(params.app
? {
Expand Down
3 changes: 2 additions & 1 deletion packages/provider-revyl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"supportsArtifacts": false,
"supportsDeferredAppSelection": false,
"supportsDirectPortReverse": false,
"usesCloudWebDriverLease": false
"usesCloudWebDriverLease": false,
"holdsCloudDevice": true
},
"credentialVariables": [
"REVYL_API_KEY",
Expand Down
1 change: 1 addition & 0 deletions packages/provider-revyl/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ test('the plugin declares what a Revyl lease is and is not', () => {
supportsDeferredAppSelection: false,
supportsDirectPortReverse: false,
usesCloudWebDriverLease: false,
holdsCloudDevice: true,
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/provider-revyl/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function revyl(options: RevylPluginOptions = {}): AgentDevicePlugin {
supportsDeferredAppSelection: false,
supportsDirectPortReverse: false,
usesCloudWebDriverLease: false,
holdsCloudDevice: true,
},
resolve: (context) =>
import('./connection.ts').then(({ createRevylConnection }) =>
Expand Down
13 changes: 5 additions & 8 deletions src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ import {
materializeRemoteConnectionForCommand,
} from './test-utils/host-bound-commands.ts';
import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts';
import {
CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS,
PROXY_REMOTE_LEASE_TTL_MS,
} from '../cli/commands/connection-lease-policy.ts';
import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope';
import { hasDeferredMetroConfig } from '../cli/commands/connection-metro-runtime.ts';
import { stopMetroTunnel } from '@agent-device/react-native';
import { AppError } from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -629,7 +626,7 @@ test('proxy open resolves device key before allocating lease', async () => {
assert.equal(allocate.request?.leaseProvider, 'proxy');
assert.equal(allocate.request?.clientId, 'client-1');
assert.equal(allocate.request?.deviceKey, 'ios:mobile:SIM-001');
assert.equal(allocate.request?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS);
assert.equal(allocate.request?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS);
assert.equal(allocate.request?.leaseBackend, 'ios-instance');
assert.equal(materialized.flags.leaseId, 'abc123abc123abc1');
assert.equal(materialized.flags.udid, 'SIM-001');
Expand Down Expand Up @@ -683,7 +680,7 @@ test.each(['install', 'uninstall'])('proxy %s allocates a lease first', async (c
assert.equal(allocate.request?.leaseProvider, 'proxy');
assert.equal(allocate.request?.clientId, 'client-1');
assert.equal(allocate.request?.deviceKey, 'android:mobile:emulator-5554');
assert.equal(allocate.request?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS);
assert.equal(allocate.request?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS);
assert.equal(allocate.request?.leaseBackend, 'android-instance');
assert.equal(materialized.flags.leaseId, 'android-lease-1');
assert.equal(materialized.flags.serial, 'emulator-5554');
Expand Down Expand Up @@ -783,7 +780,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT
});

assert.equal(allocateRequest?.leaseProvider, 'aws-device-farm');
assert.equal(allocateRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS);
assert.equal(allocateRequest?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS);
assert.equal(materialized.flags.leaseId, 'cloud-lease-1');

let heartbeatRequest: Parameters<AgentDeviceClient['leases']['heartbeat']>[0] | undefined;
Expand All @@ -805,7 +802,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT
});

assert.equal(heartbeatRequest?.leaseId, 'cloud-lease-1');
assert.equal(heartbeatRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS);
assert.equal(heartbeatRequest?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS);
fs.rmSync(tempRoot, { recursive: true, force: true });
});

Expand Down
1 change: 1 addition & 0 deletions src/builtin-provider-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const LIMRUN_CONNECTION: ConnectionProviderCapabilities = {
supportsDeferredAppSelection: true,
supportsDirectPortReverse: true,
usesCloudWebDriverLease: false,
holdsCloudDevice: true,
};

/** Every provider id agent-device registers itself; a plugin cannot claim any of them. */
Expand Down
48 changes: 48 additions & 0 deletions src/cli/commands/connection-lease-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test, vi } from 'vitest';
import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote';
import type { RemoteConnectionState } from '@agent-device/remote/remote-connection-state';
import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope';
import { connectionLeasePolicyForState } from './connection-lease-policy.ts';

const LOCAL_PROVIDER: ConnectionProviderCapabilities = {
leaseKind: 'direct-device-provider',
requiresAppAttachment: false,
requiresRemoteDaemon: false,
supportsArtifacts: false,
supportsDeferredAppSelection: false,
supportsDirectPortReverse: false,
usesCloudWebDriverLease: false,
};

const providers: Record<string, ConnectionProviderCapabilities> = {
proxy: { ...LOCAL_PROVIDER, leaseKind: 'proxy', requiresRemoteDaemon: true },
'cloud-device': { ...LOCAL_PROVIDER, holdsCloudDevice: true },
'cloud-device-deferred': {
...LOCAL_PROVIDER,
supportsDeferredAppSelection: true,
holdsCloudDevice: true,
},
'cloud-webdriver': { ...LOCAL_PROVIDER, usesCloudWebDriverLease: true },
'local-device': LOCAL_PROVIDER,
'malformed-flag': { ...LOCAL_PROVIDER, holdsCloudDevice: 'false' as never },
};

vi.mock('../connection/provider-policy.ts', () => ({
connectionProviderCapabilities: (provider: string) => providers[provider],
}));

function ttlFor(leaseProvider: string): number | undefined {
const state = { leaseProvider } as RemoteConnectionState;
return connectionLeasePolicyForState(state).ttlMs(state);
}

test.each([
['proxy', REMOTE_LEASE_IDLE_TTL_MS],
['cloud-device', REMOTE_LEASE_IDLE_TTL_MS],
['cloud-device-deferred', REMOTE_LEASE_IDLE_TTL_MS],
['cloud-webdriver', REMOTE_LEASE_IDLE_TTL_MS],
['local-device', undefined],
['malformed-flag', undefined],
])('a %s lease requests TTL %s', (provider, expected) => {
expect(ttlFor(provider)).toBe(expected);
});
26 changes: 12 additions & 14 deletions src/cli/commands/connection-lease-policy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope';
import { AppError } from '@agent-device/kernel/errors';
import type { LeaseBackend } from '@agent-device/kernel/contracts';
import type { CliFlags } from '@agent-device/contracts/command';
Expand All @@ -17,8 +18,14 @@
'metro',
'session',
]);
export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000;
export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000;
function remoteLeaseTtlMs(state: RemoteConnectionState): number | undefined {
Comment thread
thymikee marked this conversation as resolved.
Resolved
const capabilities = connectionProviderCapabilities(state.leaseProvider);
return capabilities.leaseKind === 'proxy' ||
capabilities.usesCloudWebDriverLease ||
capabilities.holdsCloudDevice === true
? REMOTE_LEASE_IDLE_TTL_MS
: undefined;
}

Check warning on line 28 in src/cli/commands/connection-lease-policy.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: Cloud-device and holdsCloudDevice providers now always request 5m, so a daemon with a lower AGENT\_DEVICE\_LEASE\_MAX\_TTL\_MS fails every lease-using command

remoteLeaseTtlMs (connection-lease-policy.ts:21-28) now returns REMOTE\_LEASE\_IDLE\_TTL\_MS for any provider whose capabilities carry `holdsCloudDevice === true` or `usesCloudWebDriverLease`, where the predecessor returned `undefined` for both the DEFERRED\_APP\_SELECTION and DEFAULT policies — the daemon then used its 60s default (lease-registry-scope.ts:100,115). The daemon validates an explicit ttl against `AGENT_DEVICE_LEASE_MAX_TTL_MS` and throws INVALID\_ARGS when it is above max (packages/daemon/src/resources/lease-registry-scope.ts:111-119), and `heartbeatOrAllocateLease` only swallows inactive-lease errors, so the refusal propagates. Concretely: an operator who runs the remote daemon with `AGENT_DEVICE_LEASE_MAX_TTL_MS=120000` (an operator-facing knob documented in this repo, website/docs/docs/webos.md:92 and packages/provider-webos/README.md:52) gets `INVALID_ARGS: Lease ttlMs must be between ...` on the first allocate _and_ on every heartbeat for Limrun, Revyl and any cloud-WebDriver provider, where the same commands worked before this PR. website/docs/docs/plugins.md:131 states this refusal for plugins; website/docs/docs/limrun.md and the Revyl docs say nothing about it, and neither mentions that a lower daemon max now breaks those providers entirely.

Check warning on line 28 in src/cli/commands/connection-lease-policy.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: A CLI heartbeat now overwrites a longer externally-allocated lease TTL with five minutes

`remoteLeaseTtlMs` always returns `REMOTE_LEASE_IDLE_TTL_MS` for a proxy, cloud-WebDriver or `holdsCloudDevice` provider, and `allocateOrReuseLease` passes it on every command: `heartbeatOrAllocateLease(..., ttlMs: policy.ttlMs(state))`. The daemon treats an explicit heartbeat ttl as authoritative and shortens the lease: `lease-registry.ts:231` uses `request.ttlMs === undefined ? leaseOwnTtlMs(lease) : this.resolveLeaseTtlMs(request.ttlMs)`, and `lease-registry.test.ts:947` ('a heartbeat with an explicit ttlMs still sets that window') asserts that shortening. Before this diff, Limrun and Revyl resolved to the deferred/default policy whose `ttlMs` returned `undefined`, so a heartbeat renewed the lease for the window the lease already carried. Now, the harness flow documented in `website/docs/docs/limrun.md:148` allocates a lease over the RPC with its own long `ttlMs` and heartbeats between sessions; the first CLI command on that `leaseId` (or the next provider command in the same process) renews it for 300000 ms, so a lease deliberately held for, say, ten minutes expires after five idle minutes and the harness's next `leases.heartbeat` gets an inactive-lease error. The same applies to an external ten-minute BrowserStack lease, which previously heartbeated at its own length (10 min) and now at five.

export type ConnectionLeasePolicy = {
shouldAllocate(command: string): boolean;
Expand All @@ -43,28 +50,25 @@
if (capabilities.supportsDeferredAppSelection) {
return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY;
}
if (capabilities.usesCloudWebDriverLease) {
return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY;
}
return DEFAULT_CONNECTION_LEASE_POLICY;
}

const DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = {
shouldAllocate: (command) =>
command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command),
ttlMs: () => undefined,
ttlMs: remoteLeaseTtlMs,
resolveLeaseState: async (options) => ({ state: options.state }),
};

const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = {
shouldAllocate: (command) => !leaseDeferredCommands.has(command),
ttlMs: () => undefined,
ttlMs: remoteLeaseTtlMs,
resolveLeaseState: async (options) => ({ state: options.state }),
};

const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = {
shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command),
ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS,
ttlMs: remoteLeaseTtlMs,
// Known before the daemon allocates, so a host in front of it can name the lease with the same id.
newLeaseId: () => crypto.randomBytes(16).toString('hex'),
resolveLeaseState: resolveProxyLeaseState,
Expand All @@ -87,9 +91,3 @@
return { state: { ...state, platform: 'macos' } };
},
};

const CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = {
shouldAllocate: (command) => !leaseDeferredCommands.has(command),
ttlMs: () => CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS,
resolveLeaseState: async (options) => ({ state: options.state }),
};
1 change: 1 addition & 0 deletions src/cli/connection/provider-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ test('provider policy projects provider identity into semantic capabilities', ()
supportsDeferredAppSelection: true,
supportsDirectPortReverse: true,
usesCloudWebDriverLease: false,
holdsCloudDevice: true,
});
const browserStack = connectionProviderCapabilities('browserstack');
assert.equal(browserStack.supportsArtifacts, true);
Expand Down
7 changes: 7 additions & 0 deletions website/docs/docs/limrun.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ If your shell's account variables, or its instance variables for the platform yo

Limrun ends an instance after its inactivity timeout, which an idle session can reach while a model thinks between steps. Set `LIMRUN_KEEP_ALIVE=1` (or `true`) to ping the instance every 30 seconds while a session is open. Keep-alive is off by default. In the Node.js runtime, pass `keepAlive: true`.

Two timers can end an idle Limrun session.

- The session lease. Each command that uses the lease renews it for five minutes. After five minutes without such a command the lease expires, and the daemon ends the session. The next command that uses the lease allocates a new lease, and the app you opened is not restored.
- The Limrun instance. An instance that agent-device creates ends after five minutes without activity, the same window as the lease. An instance you attach to keeps the inactivity timeout it was created with. When Limrun ends an instance, every later command fails with `WebSocket is not connected` until the lease expires or you run `disconnect`.

`LIMRUN_KEEP_ALIVE` keeps the instance running past five minutes. It does not renew the lease.

## Run a session from the CLI

A new Limrun instance does not contain your app. Run `install <package-or-bundle-id> <app-path-or-url>` before `open`. `install` allocates the instance when needed, so you do not need to run `devices` first.
Expand Down
3 changes: 2 additions & 1 deletion website/docs/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,14 @@ connection: {
supportsDeferredAppSelection: true,
supportsDirectPortReverse: false,
usesCloudWebDriverLease: false,
holdsCloudDevice: false,
},
resolve: async ({ flags, env, cwd, stateDir, host }) => ({ profile }),
verify: async ({ flags, env, host }) => verification,
},
```

`resolve` validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the plugin's `provider`. The host supplies connection identity, session defaults, Metro settings, and persists the profile. `verify` returns the provider verification result. Both also receive `host`, the same `ProviderPluginHost` that `setup` gets, built from the `env` of that call. Both run without allocating a device or setting up a runtime. `agent-device` loads installed plugins' factories, not their `setup`, to read these capabilities.
`resolve` validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the plugin's `provider`. The host supplies connection identity, session defaults, Metro settings, and persists the profile. `verify` returns the provider verification result. Both also receive `host`, the same `ProviderPluginHost` that `setup` gets, built from the `env` of that call. Both run without allocating a device or setting up a runtime. `agent-device` loads installed plugins' factories, not their `setup`, to read these capabilities. Set the optional `holdsCloudDevice: true` when the provider leases a cloud-hosted device. Each command that uses the lease then requests a five-minute lease window, the same as a proxy or cloud WebDriver lease, not the 60-second daemon default. A daemon whose `AGENT_DEVICE_LEASE_MAX_TTL_MS` is below five minutes refuses that request with `INVALID_ARGS`.

### Package for installation

Expand Down
Loading