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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir <path>`. The takeover notice moves from `Replacing daemon … : <reason>` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir <path>`. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22.
- Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22.
- `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider` and `details.registeredProviders`. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry.
- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.
Expand Down
13 changes: 13 additions & 0 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
findMissingProxyLeaseFields,
isInactiveLeaseError,
leaseDeviceKey,
leaseProviderRequiringRuntime,
leaseScopeFromOptions,
leaseScopeFromRequest,
leaseScopeToAllocateRequest,
Expand Down Expand Up @@ -289,3 +290,15 @@ test('parseLeaseDeviceKey reads back every field leaseDeviceKey writes', () => {
assert.equal(parseLeaseDeviceKey(malformed), undefined, `${malformed} is not a device key`);
}
});

test('leaseProviderRequiringRuntime exempts only provider-less, proxy, and cloud leases', () => {
assert.equal(leaseProviderRequiringRuntime({}), undefined);
assert.equal(leaseProviderRequiringRuntime({ leaseBackend: 'ios-instance' }), undefined);
assert.equal(leaseProviderRequiringRuntime({ leaseProvider: 'proxy' }), undefined);
assert.equal(leaseProviderRequiringRuntime({ leaseProvider: 'cloud' }), undefined);
assert.equal(leaseProviderRequiringRuntime({ leaseProvider: 'testmu' }), 'testmu');
assert.equal(
leaseProviderRequiringRuntime({ leaseProvider: 'Cloud', leaseBackend: 'ios-instance' }),
'Cloud',
);
});
21 changes: 21 additions & 0 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import type { CommandFlags } from './command-flags.ts';
import type { EndedLease } from './client-lease.ts';

const PROXY_LEASE_PROVIDER = 'proxy';
export const CLOUD_LEASE_PROVIDER = 'cloud';

/**
* Lease providers that name a logical routing identity rather than a provider runtime. Any other
* `leaseProvider` is backed by a registered runtime or is refused at allocation.
*/
const RUNTIME_LESS_LEASE_PROVIDERS: ReadonlySet<string> = new Set([
PROXY_LEASE_PROVIDER,
CLOUD_LEASE_PROVIDER,
]);

const REQUIRED_PROXY_LEASE_FIELDS = [
'leaseId',
Expand Down Expand Up @@ -328,6 +338,17 @@ export function isProxyLeaseScope(scope: LeaseScope): boolean {
return scope.leaseProvider === PROXY_LEASE_PROVIDER;
}

/**
* The provider whose registered runtime must back this lease, or `undefined` for a logical lease:
* one with no provider (a `leaseBackend` alone names no provider), `proxy`, or `cloud`.
*/
export function leaseProviderRequiringRuntime(scope: LeaseScope): string | undefined {
const { leaseProvider } = scope;
return leaseProvider === undefined || RUNTIME_LESS_LEASE_PROVIDERS.has(leaseProvider)
? undefined
: leaseProvider;
}

export function findMissingProxyLeaseFields(scope: LeaseScope): string[] {
if (!isProxyLeaseScope(scope)) return [];
return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test.for([
token: 'test-token',
sessionStore: makeSessionStore('agent-device-router-provider-credentials-store-'),
leaseRegistry: new LeaseRegistry(),
providerRuntimeIds: ['limrun'],
deviceInventoryGateways: createTestDeviceInventoryGateways(),
providerCredentials: providerCredentialsFixture(DAEMON_ENV, stateDir),
leaseLifecycleProvider: {
Expand Down
1 change: 0 additions & 1 deletion packages/daemon/src/server/http/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ export type DaemonRuntimeDeps = Readonly<{
| 'createCommandSurface'
| 'cloudArtifactProvider'
| 'providerAppCatalog'
| 'providerRuntimeRequiredIds'
| 'providerDeviceRuntimeScope'
>;
}>;
Expand Down
3 changes: 0 additions & 3 deletions packages/daemon/src/server/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ export type RequestRouterDeps = {
screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger;
hostDiagnostics?: HostDiagnostics;
providerRuntimeIds?: readonly string[];
providerRuntimeRequiredIds?: readonly string[];
providerCredentials: DaemonProviderCredentials;
leaseLifecycleProvider?: LeaseLifecycleProvider;
cloudArtifactProvider?: CloudArtifactProvider;
Expand Down Expand Up @@ -161,7 +160,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(),
hostDiagnostics,
providerRuntimeIds,
providerRuntimeRequiredIds,
providerCredentials,
leaseLifecycleProvider,
cloudArtifactProvider,
Expand Down Expand Up @@ -351,7 +349,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
leaseRegistry,
leaseLifecycleProvider,
providerRuntimeIds,
providerRuntimeRequiredIds,
providerCredentials,
cloudArtifactProvider,
endSessionsForLease,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ test('lease handler preserves device-aware lease fields', async () => {
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
});

assert.equal(allocateResponse?.ok, true);
Expand Down Expand Up @@ -234,6 +235,7 @@ test('lease release calls provider hook using the released lease without heartbe
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
});
assert.equal(allocateResponse?.ok, true);
const lease = readLeaseResponse(allocateResponse);
Expand Down Expand Up @@ -315,6 +317,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
leaseLifecycleProvider: {
allocate: async (_lease, context) => {
observed = {
Expand Down Expand Up @@ -362,6 +365,7 @@ test('a lease allocated for a requester that left is released, not registered',
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
leaseLifecycleProvider: {
allocate: async (lease) => {
// The client disconnects mid-allocation; the provider finishes anyway.
Expand Down Expand Up @@ -410,6 +414,7 @@ test('a failed provider release after cancellation is reported as unreleased wit
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
leaseLifecycleProvider: {
allocate: async () => {
markRequestCanceled(requestId);
Expand Down Expand Up @@ -456,6 +461,7 @@ test('a throwing provider release after cancellation is reported as unreleased,
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
providerRuntimeIds: ['fake-provider'],
leaseLifecycleProvider: {
allocate: async () => {
markRequestCanceled(requestId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ test('artifacts refuses a provider session returned after a canceled allocation
sessionName: 'artifact-test',
sessionStore: world.sessionStore,
leaseRegistry: world.leaseRegistry,
providerRuntimeIds: [CLOUD_PROVIDER],
leaseLifecycleProvider: world.lifecycle,
}),
isRequestCanceledError,
Expand Down Expand Up @@ -253,6 +254,7 @@ async function allocateLease(world: World, tenantId: string, runId: string): Pro
sessionName: 'artifact-test',
sessionStore: world.sessionStore,
leaseRegistry: world.leaseRegistry,
providerRuntimeIds: [CLOUD_PROVIDER],
leaseLifecycleProvider: world.lifecycle,
});
assert.equal(response?.ok, true);
Expand All @@ -272,6 +274,7 @@ async function releaseLease(world: World, lease: DeviceLease): Promise<void> {
sessionName: 'artifact-test',
sessionStore: world.sessionStore,
leaseRegistry: world.leaseRegistry,
providerRuntimeIds: [CLOUD_PROVIDER],
leaseLifecycleProvider: world.lifecycle,
});
assert.equal(response?.ok, true);
Expand Down
104 changes: 104 additions & 0 deletions packages/daemon/src/sessions/handlers/__tests__/lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,107 @@ test("a proxy allocation naming another owner's lease id still picks and is refu
error.code === 'INVALID_ARGS' && error.details?.reason === 'LEASE_ID_IN_USE',
);
});

const RUNTIME_LESS_LEASES: readonly (readonly [string, Record<string, string>])[] = [
['no provider', {}],
['a backend and no provider', { leaseBackend: 'ios-instance' }],
['the proxy routing identity', { leaseProvider: 'proxy' }],
['the cloud remote profile', { leaseProvider: 'cloud' }],
];

for (const [label, scope] of RUNTIME_LESS_LEASES) {
test(`lease_allocate keeps a runtime-less logical lease with ${label}`, async () => {
const registry = new LeaseRegistry();
const response = await handleLeaseCommands({
req: {
token: 'test-token',
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: { tenantId: 'tenant-a', runId: 'run-1', ...scope },
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-logical-lease-'),
leaseRegistry: registry,
providerRuntimeIds: ['browserstack'],
});
assert.equal(response?.ok, true);
assert.equal(registry.listActiveLeases().length, 1);
});
}

test('lease_allocate refuses a provider no registered runtime claims, before recording a lease', async () => {
const registry = new LeaseRegistry();
await assert.rejects(
handleLeaseCommands({
req: {
token: 'test-token',
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: { tenantId: 'tenant-a', runId: 'run-1', leaseProvider: 'testmu' },
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-unregistered-provider-'),
leaseRegistry: registry,
providerRuntimeIds: ['browserstack'],
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
assert.equal(error.details?.reason, 'provider-not-registered');
assert.equal(error.details?.provider, 'testmu');
assert.deepEqual(error.details?.registeredProviders, ['browserstack']);
return true;
},
);
assert.deepEqual(registry.listActiveLeases(), []);
});

test('lease_allocate treats a daemon that reports no provider runtimes as having none registered', async () => {
await assert.rejects(
handleLeaseCommands({
req: {
token: 'test-token',
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: { tenantId: 'tenant-a', runId: 'run-1', leaseProvider: 'browserstack' },
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-no-runtimes-'),
leaseRegistry: new LeaseRegistry(),
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
assert.equal(error.details?.reason, 'provider-not-registered');
assert.equal(error.details?.provider, 'browserstack');
assert.deepEqual(error.details?.registeredProviders, []);
return true;
},
);
});

test('lease_allocate classifies the trimmed provider id and rejects a malformed one as INVALID_ARGS', async () => {
const allocate = async (leaseProvider: string, providerRuntimeIds: string[]) =>
await handleLeaseCommands({
req: {
token: 'test-token',
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: { tenantId: 'tenant-a', runId: 'run-1', leaseProvider },
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-provider-id-forms-'),
leaseRegistry: new LeaseRegistry(),
providerRuntimeIds,
});

assert.equal((await allocate(' testmu ', ['testmu']))?.ok, true);
await assert.rejects(
allocate('a b', ['testmu']),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS',
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function allocateWithDaemonCredentials(
sessionName: req.session,
sessionStore: makeSessionStore('agent-device-provider-credentials-'),
leaseRegistry: registry,
providerRuntimeIds: [req.meta?.leaseProvider?.trim() ?? ''],
providerCredentials,
leaseLifecycleProvider: {
allocate: async () => {
Expand Down Expand Up @@ -158,3 +159,14 @@ test('a lease compares the daemon fingerprint of the leased backend only', async
['limrun', 'android-instance'],
]);
});

test('a trimmed provider id is compared against the daemon credentials of the trimmed provider', async () => {
const outcome = await allocateWithDaemonCredentials(
allocateRequest(' limrun ', shellFingerprint({ KEY: 'key-2' })),
providerCredentialsFixture({ KEY: 'key-1' }, STATE_DIR),
);

assert.equal(outcome.allocations, 0);
assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed');
assert.equal(outcome.error?.details?.provider, 'limrun');
});
35 changes: 14 additions & 21 deletions packages/daemon/src/sessions/handlers/lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { LeaseRegistry } from '../../resources/lease-registry.ts';
import {
leaseReleaseRequestFor,
normalizeLeaseBackend,
normalizeLeaseProvider,
type ReleaseLeaseRequest,
} from '../../resources/lease-registry-scope.ts';
import type { SessionStore } from '../../core/session-store.ts';
Expand All @@ -24,6 +25,7 @@ import {
} from '../../core/lease-context.ts';
import {
leaseDeviceKey,
leaseProviderRequiringRuntime,
leaseScopeToAllocateRequest,
leaseScopeToHeartbeatRequest,
leaseScopeToReleaseRequest,
Expand All @@ -45,7 +47,6 @@ type LeaseHandlerArgs = {
sessionStore: SessionStore;
leaseRegistry: LeaseRegistry;
providerRuntimeIds?: readonly string[];
providerRuntimeRequiredIds?: readonly string[];
providerCredentials?: DaemonProviderCredentials;
leaseLifecycleProvider?: LeaseLifecycleProvider;
cloudArtifactProvider?: CloudArtifactProvider;
Expand All @@ -59,7 +60,6 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
sessionStore,
leaseRegistry,
providerRuntimeIds,
providerRuntimeRequiredIds,
providerCredentials,
leaseLifecycleProvider,
cloudArtifactProvider,
Expand All @@ -81,13 +81,12 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
}
case 'lease_allocate': {
assertTenantMayAllocate(leaseScope.leaseBackend);
assertProviderRuntimeAvailable(
leaseScope.leaseProvider,
providerRuntimeIds,
providerRuntimeRequiredIds,
);
// The registry stores the trimmed, validated id, so every admission check reads that id:
// " testmu " names testmu, and a malformed id is INVALID_ARGS as everywhere else.
const leaseProvider = normalizeLeaseProvider(leaseScope.leaseProvider);
assertProviderRuntimeRegistered(leaseProvider, providerRuntimeIds ?? []);
assertProviderCredentialsUnchanged(
leaseScope,
{ leaseProvider, leaseBackend: leaseScope.leaseBackend },
req.meta?.providerCredentialFingerprint,
providerCredentials,
);
Expand Down Expand Up @@ -334,25 +333,19 @@ function leaseLifecycleContext(req: DaemonRequest): LeaseLifecycleContext {
};
}

function assertProviderRuntimeAvailable(
provider: string | undefined,
providerRuntimeIds: readonly string[] | undefined,
providerRuntimeRequiredIds: readonly string[] | undefined,
function assertProviderRuntimeRegistered(
leaseProvider: string | undefined,
registeredProviders: readonly string[],
): void {
if (
!provider ||
providerRuntimeIds === undefined ||
providerRuntimeRequiredIds === undefined ||
!providerRuntimeRequiredIds.includes(provider) ||
providerRuntimeIds.includes(provider)
) {
return;
}
const provider = leaseProviderRequiringRuntime({ leaseProvider });
if (provider === undefined || registeredProviders.includes(provider)) return;
throw new AppError(
'UNSUPPORTED_OPERATION',
`Provider "${provider}" is not available in this daemon runtime.`,
{
reason: 'provider-not-registered',
provider,
registeredProviders: [...registeredProviders],
hint: `Restart the daemon with ${provider} configured, then retry lease allocation.`,
},
);
Expand Down
Loading
Loading