Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d4151ce
refactor(providers): share one lease table across cloud providers
thymikee Nov 2, 2026
d646a73
refactor(providers): move the lease table to provision-kit
thymikee Nov 2, 2026
e429ee1
fix(providers): fence lease teardown and keep uncertain rentals owed
thymikee Nov 2, 2026
572f802
build(provider-revyl): reference provision-kit from the TypeScript pr…
thymikee Nov 2, 2026
85259a8
refactor(provision-kit): split the lease table's start and teardown s…
thymikee Nov 2, 2026
f504227
fix(providers): hand a ready device only to callers still within budget
thymikee Nov 2, 2026
9df80ce
docs(providers): name each lease refusal reason and scope the digest …
thymikee Nov 2, 2026
b25dc6c
test(revyl): record only Revyl API calls in the fixture backend
thymikee Nov 2, 2026
c7a089d
fix(revyl): keep a shared start running when its first caller hangs up
thymikee Nov 2, 2026
fc94661
test(revyl): record a fixture request only when it carries the API key
thymikee Nov 2, 2026
edb5034
fix(providers): run a shared start for every waiter, and type the pri…
thymikee Nov 2, 2026
f82368b
fix(provision-kit): keep every named rental owed, and check budgets a…
thymikee Nov 2, 2026
48f6a31
fix(provision-kit): fence an abandoned start, and harden the principal
thymikee Nov 2, 2026
03fc49e
chore(providers): name Revyl's readiness margin and correct the scryp…
thymikee Nov 2, 2026
8393630
fix(provision-kit): report every rental a teardown leaves rented
thymikee Nov 2, 2026
be5dab0
fix(provision-kit): report rentals that wait behind a stop still in f…
thymikee Nov 2, 2026
c85ab81
chore(providers): name the scrypt parameters and record what an unaut…
thymikee Nov 2, 2026
e95dd20
refactor(provision-kit): drive each lease through one explicit phase
thymikee Nov 2, 2026
9e413be
test(provision-kit): sweep seeded interleavings of the lease table
thymikee Nov 2, 2026
0ae8a27
test(provision-kit): name the interleaving sweep for its module's aspect
thymikee Nov 2, 2026
8db5cc9
fix(provision-kit): look for new rentals before a teardown forgets it…
thymikee Nov 2, 2026
e40a616
docs(revyl): retry a released open with open, not connect
thymikee Nov 2, 2026
327a216
docs(revyl): point a released open at open, and state the 370 s ceiling
thymikee Nov 2, 2026
831f01b
docs(adr): say that a release only joins a lease entry that already e…
thymikee Nov 2, 2026
b6aa27b
fix(providers): stop a released Revyl start without waiting out readi…
thymikee Nov 2, 2026
0c401f9
fix(providers): let teardown alone abort a start, and pin readiness a…
thymikee Nov 2, 2026
dce5c56
fix(provision-kit): stop a late rental even when a driver's error fac…
thymikee Nov 2, 2026
c8d4584
fix(provision-kit): name the lease's end in every refusal a caller gets
thymikee Nov 2, 2026
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
18 changes: 17 additions & 1 deletion docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Accepted

Decision history: 2026-10-21, a `macos-app` lease session takes its app device's claim instead of no claim, so exclusion against whole-Mac and same-app sessions also holds across daemons (ADR 0035, #3323).
Decision history: 2026-10-21, a `macos-app` lease session takes its app device's claim instead of no claim, so exclusion against whole-Mac and same-app sessions also holds across daemons (ADR 0035, #3323). 2026-11-02, the Revyl provider runtime serves its lease lifecycle from a shared lease table instead of a table of its own, and its runtime-owner instance widens to the shared 32-byte digest, which Revyl keeps in-process only (#4102).

## Rules at a glance

Expand All @@ -13,6 +13,7 @@ Decision history: 2026-10-21, a `macos-app` lease session takes its app device's
- Do not mutate lease ownership before the lock. Scope resolution may happen before the lock.
- Generated connection profiles must strip daemon and Metro bearer tokens.
- A session cannot outlive its lease. Lease-end teardown clears the session's device claim even when the device no longer answers.
- A cloud provider runtime forgets a rented device only after the provider confirmed its stop, and it refuses to allocate a lease that it is ending.
- Report each lease that a request ended once, at response level. A client never sends a release after such a request.
- Admitted work that is still wanted preserves its lease. If the client hung up, the request neither defers expiry past that cancellation nor renews the lease.
- Before admission, send lease heartbeats for as long as a client phase runs, for example an artifact upload. Renew under the window that the caller named, or else under the lease's existing window.
Expand Down Expand Up @@ -161,6 +162,21 @@ The lease cannot expire during that allocation.
If a successful allocation is still wanted by its requester, the inactivity TTL starts from the moment allocation completed.
A requester that hung up preserves nothing, and its allocation is released as before.

The Revyl provider runtime serves allocation, release, expired-lease recovery, and shutdown from a shared lease table, `@agent-device/provision-kit/provider-lease-table`.
The provider supplies its start timeout, an optional stop timeout, start, stop, find-by-lease, a key that identifies a rental, and the errors that it reports.
The table owns the rules that decide whether a billed device is still rented:

- One lease id has at most one start. Overlapping allocations join it, and a ready session is reused.
- Each caller waits until the earlier of its own deadline and the start timeout. A caller whose budget is already spent sends no start. A caller that gives up stops nothing. The start runs while any caller still waits, for at most the start timeout: no single caller's signal or deadline reaches it, and the table tells the provider to abandon it once no caller waits or the lease starts ending. A ready device goes to the callers still waiting within their budgets, and it is stopped when no caller waits. Each caller's budget is checked when the session is handed over, on reuse as well.
- A start can publish its rental before it answers. A teardown waits for the answer for at most the start timeout, then stops the published rental and keeps the lease pending until the answer arrives.
- Release, recovery, a failed start, and shutdown each end a lease through one teardown that all of them join. The teardown fences the lease in the same step: from then on, an allocation of the lease is refused, including an allocation that was already waiting, and the device inventory and interactor no longer serve the lease. Revyl reports a refusal for a lease that a release, a recovery, or a failed start is ending as `provider-lease-releasing`, and a refusal after shutdown began as `runtime-shutting-down`.
- A teardown forgets a lease only after the provider confirmed the stop of every rental that it knows. It stops them one at a time, in the order they were named, and a rental named while a teardown settles gets a teardown of its own. A failed or late stop keeps the lease for the next teardown, which joins the stop still in flight. Shutdown is a fence: from then on, an allocation, or a recovery of a lease that has no entry, is refused with `runtime-shutting-down`, and a release only joins an entry that already exists, so one drain pass ends every entry. It reports what stays rented with `provider-cleanup-pending`.
- If a start fails without an answer, or a recovery finds no entry, the teardown asks find-by-lease. If the provider cannot name the rental, the lease stays pending, and the next teardown asks again.

A table in each provider was rejected: each provider derived the same rules again, and most review findings on the second provider (#4092) were about that bookkeeping.
Contracts was rejected as the home, because the table is lifecycle mechanics and not wire vocabulary. Capture-kit was rejected, because renting a device prepares it for execution, which ADR 0019 gives to provision-kit.
Open gap: Limrun keeps its own lease ownership until it moves onto the table (#4102).

## Client-side work that precedes admission

The protection of admitted work covers nothing that happens before a request is admitted.
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ Its workspace compliance includes `check:affected` selection, R11/R13 package en
> - Host toolchain readiness probes.
> - Device boot-failure classification.
> - App-resolution caches.
> - The lease table that cloud device provider runtimes rent devices through (ADR 0007).
> Platform packages may import provision-kit.
> Provision-kit may not import a platform package.
> Capture-kit may not import provision-kit.
Expand Down
71 changes: 71 additions & 0 deletions packages/contracts/src/platform-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { AppError } from '@agent-device/kernel/errors';
import {
localRuntimeOwner,
narrowDeviceBinding,
providerRuntimeInstance,
type ProviderRuntimePrincipalField,
providerRuntimeOwner,
runtimeOwnerKey,
sameRuntimeOwner,
Expand Down Expand Up @@ -213,6 +215,75 @@ function testBinding(
};
}

const LIMRUN_PRINCIPAL: readonly ProviderRuntimePrincipalField[] = [
['region', 'eu'],
['apiKey', 'secret-key'],
['attachedIos', undefined],
['attachedAndroid', undefined],
];

/**
* The digest is durable: a provider descriptor written before this release names the instance its
* runtime derived, so the pin is the contract. A change here orphans every live descriptor.
*/
test('providerRuntimeInstance keeps the digest durable descriptors carry', () => {
assert.equal(
providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL),
'principal-1426b5849137d86f197cdb796882e8f619e8be77eda1b424144502e1da55af29',
);
// The salt carries the provider, so one set of credentials is a different account elsewhere.
assert.notEqual(
providerRuntimeInstance('other-provider', LIMRUN_PRINCIPAL),
providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL),
);
// The provider is normalized as `providerRuntimeOwner` normalizes it, so one account cannot
// arrive as two owners through spelling.
assert.equal(
providerRuntimeInstance(' limrun ', LIMRUN_PRINCIPAL),
providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL),
);
// An unset fact is the same account as an absent one, never a second instance.
assert.equal(
providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL.slice(0, 2)),
providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL),
);
// No secret is readable from what travels into diagnostics and durable state.
assert.ok(!providerRuntimeInstance('limrun', LIMRUN_PRINCIPAL).includes('secret-key'));
});

test('providerRuntimeInstance refuses a principal that cannot name one account', () => {
assert.throws(() => providerRuntimeInstance(' ', LIMRUN_PRINCIPAL), /non-empty provider/);
assert.throws(
() => providerRuntimeInstance('limrun', [['apiKey', undefined]]),
/at least one fact/,
);
assert.throws(
() =>
providerRuntimeInstance('limrun', [
['apiKey', 'a'],
['apiKey', 'b'],
]),
/empty or repeated/,
);
assert.throws(() => providerRuntimeInstance('limrun', [['provider', 'x']]), /empty or repeated/);
// A repeated name is refused even when one of the two is unset.
assert.throws(
() =>
providerRuntimeInstance('limrun', [
['apiKey', undefined],
['apiKey', 'b'],
]),
/empty or repeated/,
);
// JSON would move an integer-like name ahead of the declared order.
assert.throws(() => providerRuntimeInstance('limrun', [['1', 'x']]), /must not be an integer/);
// `__proto__` is an ordinary fact, never a prototype that drops it from the digest.
assert.notEqual(
providerRuntimeInstance('limrun', [['__proto__', 'a']]),
providerRuntimeInstance('limrun', [['__proto__', 'b']]),
);
});

test('unavailableFact freezes the cell and omits hint when none is given', () => {
const bare = unavailableFact('unsupported-device-kind');
const hinted = unavailableFact('unsupported-provider-mode', 'provider-owned');
Expand Down
72 changes: 72 additions & 0 deletions packages/contracts/src/platform-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { scryptSync } from 'node:crypto';
import {
isPlatform,
type AppleOS,
Expand Down Expand Up @@ -76,6 +77,77 @@ export function providerRuntimeOwner(
});
}

// The key length and the scrypt cost, block size and parallelism all feed the digest: changing any of
// them re-identifies every derived instance (`maxmem` is only a cap). An instance a composition names
// outright, such as Limrun's `runtimeInstance` option, is not derived here at all. For a provider that
// writes its instance into durable state, such as Limrun's app-log descriptors, a change orphans every
// such resource; Revyl keeps its instance in-process only. Each provider pins its own digest through
// the wrapper that builds its principal.
const RUNTIME_INSTANCE_KEY_LENGTH_BYTES = 32;
Comment thread
thymikee marked this conversation as resolved.
Resolved
const RUNTIME_INSTANCE_SCRYPT_COST = 16_384;
const RUNTIME_INSTANCE_SCRYPT_BLOCK_SIZE = 8;
const RUNTIME_INSTANCE_SCRYPT_PARALLELISM = 1;
const RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY = 64 * 1024 * 1024;

/**
* One fact of a provider principal: its name, and its value, or `undefined` when this account has
* none. An unset fact is left out of the digest, exactly as an absent one.
*/
export type ProviderRuntimePrincipalField = readonly [name: string, value: string | undefined];

/**
* The runtime-owner instance for one provider account, for `providerRuntimeOwner`. The principal is
* the ordered list of facts that decide which devices one runtime rents (credentials, region, attached
* endpoints): two runtimes with the same principal share one owner. It is hashed, not carried,
* because the instance travels into diagnostics and durable envelopes.
*
* The order of the facts is part of the digest, so a provider declares them in one place and pins the
* result. `provider` is normalized as `providerRuntimeOwner` normalizes it, and the salt derives from
* it, so one set of credentials is a different instance under another provider.
*
* The digest is a synchronous, memory-hard scrypt (about 16 MiB, tens of milliseconds): derive it once
* when a runtime is built, never on a request path.
*/
export function providerRuntimeInstance(
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
provider: string,
principal: readonly ProviderRuntimePrincipalField[],
): string {
const normalizedProvider = provider.trim();
if (normalizedProvider.length === 0) {
throw new TypeError('Provider runtime instance requires a non-empty provider');
}
// No prototype, so a field named `__proto__` is an own key like any other.
const facts: Record<string, string> = Object.create(null) as Record<string, string>;
facts.provider = normalizedProvider;
Comment thread
thymikee marked this conversation as resolved.
Resolved
const names = new Set(['provider']);
for (const [name, value] of principal) {
if (name.length === 0 || names.has(name)) {
throw new TypeError(`Provider runtime principal field '${name}' is empty or repeated`);
}
// JSON orders integer-like keys first, which would break the declared field order.
if (/^\d+$/.test(name)) {
throw new TypeError(`Provider runtime principal field '${name}' must not be an integer`);
}
names.add(name);
if (value !== undefined) facts[name] = value;
}
if (Object.keys(facts).length === 1) {
throw new TypeError('Provider runtime principal needs at least one fact with a value');
}
const fingerprint = scryptSync(
Comment thread
thymikee marked this conversation as resolved.
Resolved
JSON.stringify(facts),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
`agent-device:${normalizedProvider}-runtime-owner:v1`,
RUNTIME_INSTANCE_KEY_LENGTH_BYTES,
{
N: RUNTIME_INSTANCE_SCRYPT_COST,
r: RUNTIME_INSTANCE_SCRYPT_BLOCK_SIZE,
p: RUNTIME_INSTANCE_SCRYPT_PARALLELISM,
maxmem: RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY,
},
);
return `principal-${fingerprint.toString('hex')}`;
}

export function runtimeOwnerKey(owner: RuntimeOwnerRef): string {
switch (owner.kind) {
case 'local-family':
Expand Down
17 changes: 17 additions & 0 deletions packages/provider-limrun/src/runtime-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,20 @@ test('derives an identity from attached instances without an API key', () => {
resolveLimrunRuntimeInstance({ apiKey: 'k' }),
);
});

/**
* Durable app-log descriptors name the instance a Limrun runtime derived, so these digests are the
* contract: field names, field order, and region normalization all feed them. The values are the ones
* Limrun derived before the fingerprint moved to `providerRuntimeInstance`.
*/
test('keeps the durable digest of an account and of an attached instance', () => {
expect(resolveLimrunRuntimeInstance({ apiKey: 'secret-key', region: ' EU ' })).toBe(
'principal-1426b5849137d86f197cdb796882e8f619e8be77eda1b424144502e1da55af29',
);
expect(
resolveLimrunRuntimeInstance({
apiKey: 'secret-key',
instances: { ios: { apiUrl: 'https://attached.example/api', token: 't' } },
}),
).toBe('principal-5e4bdc0668a05c1c16f984e4655fc1d1a3bcd25b5965e3b9f3a40838121d5110');
});
31 changes: 11 additions & 20 deletions packages/provider-limrun/src/runtime-instance.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { scryptSync } from 'node:crypto';
import { providerRuntimeInstance } from '@agent-device/contracts/platform-runtime';
import type { LimrunInstanceAccess } from './instance-access.ts';

const RUNTIME_INSTANCE_KEY_LENGTH = 32;
const RUNTIME_INSTANCE_SCRYPT_COST = 16_384;
const RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY = 64 * 1024 * 1024;
const RUNTIME_INSTANCE_SALT = 'agent-device:limrun-runtime-owner:v1';

/**
* The runtime-owner instance for one Limrun account: the explicit identity when the composition
* names one, otherwise the fingerprint of the facts that decide which devices this runtime rents.
*/
export function resolveLimrunRuntimeInstance(options: {
apiKey?: string;
region?: string;
Expand All @@ -17,18 +16,10 @@ export function resolveLimrunRuntimeInstance(options: {
if (!explicit) throw new TypeError('Limrun runtimeInstance must be a non-empty string');
return explicit;
}
const principal = JSON.stringify({
provider: 'limrun',
region: options.region?.trim().toLowerCase() || 'default',
apiKey: options.apiKey,
attachedIos: options.instances?.ios?.apiUrl,
attachedAndroid: options.instances?.android?.apiUrl,
});
const fingerprint = scryptSync(principal, RUNTIME_INSTANCE_SALT, RUNTIME_INSTANCE_KEY_LENGTH, {
N: RUNTIME_INSTANCE_SCRYPT_COST,
r: 8,
p: 1,
maxmem: RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY,
});
return `principal-${fingerprint.toString('hex')}`;
return providerRuntimeInstance('limrun', [
['region', options.region?.trim().toLowerCase() || 'default'],
['apiKey', options.apiKey],
['attachedIos', options.instances?.ios?.apiUrl],
['attachedAndroid', options.instances?.android?.apiUrl],
]);
}
1 change: 1 addition & 0 deletions packages/provider-revyl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@agent-device/capture-kit": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/provision-kit": "workspace:*",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
"@agent-device/provider-webdriver": "workspace:*",
"@agent-device/test-kit": "workspace:*",
"agent-device": "workspace:*"
Expand Down
5 changes: 3 additions & 2 deletions packages/provider-revyl/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ test('one account and origin is one runtime identity whatever the URL spelling',
resolveRevylRuntimeInstance({ apiKey: 'k', backendUrl: 'https://staging.revyl.test' }),
base,
);
// The identity is a hash, never the key: it travels into diagnostics.
assert.match(base, /^principal-[0-9a-f]{32}$/);
// The identity is a hash, never the key: it travels into diagnostics. The pin keeps a reordered or
// renamed principal field from re-identifying every Revyl owner without a test noticing.
assert.equal(base, 'principal-f22648ac37319e6167fe173ccefb0d9c093c4b80f96c3d757c646468b6f83957');
});
8 changes: 8 additions & 0 deletions packages/provider-revyl/src/revyl-backend.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ export async function startRevylFixtureBackend(
const raw = Buffer.concat(chunks).toString('utf8');
Comment thread
thymikee marked this conversation as resolved.
Resolved
const url = request.url ?? '/';
const method = request.method ?? 'GET';
// The fixture listens on an ephemeral localhost port that any process on the host can reach. A
Comment thread
thymikee marked this conversation as resolved.
Resolved
// request with neither the API key nor a path under Revyl's API is another process's probe and is
// not recorded. Every other request is, so a runtime call that drops the key or leaves the API
// still shows up in the assertions.
if (header(request, 'authorization') === undefined && !url.startsWith('/api/')) {
response.writeHead(404).end();
return;
}
const recorded = readJson(raw);
requests.push({
method,
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-revyl/src/revyl-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const REVYL_ATTRIBUTION = 'agent-device';

const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
/** How long `start_device` may take to answer: Revyl assigns a worker and boots the device in it. */
const REVYL_START_DEVICE_TIMEOUT_MS = 120_000;
export const REVYL_START_DEVICE_TIMEOUT_MS = 120_000;
Comment thread
thymikee marked this conversation as resolved.
Resolved
const RETRY_DELAYS_MS = [500, 1_500] as const;

/** Device-proxy actions Revyl answers to a GET; every other action is a POST. */
Expand Down
Loading
Loading