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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## Unreleased

- Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir <path>`.
- Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading.
- Bundled providers and installed plugins now register through one registry, in that order, with a third slot for embedder plugins (#3757). The first plugin to name a provider id claims it; a later duplicate is recorded as failed, and `cloud` and `proxy` stay reserved.
- A plugin that fails to load, validate, or set up no longer stops the daemon. It is listed as `failed` with a typed `error.reason`, and the other providers still register. The new `agent-device daemon status [--json]` shows the inventory, which the daemon also publishes in `daemon.json`; `plugins list --json` works without a daemon and reports a running daemon's inventory under `daemon`.
- The client reads provider credential variable names from that inventory, narrowed per lease backend, and falls back to version 1 manifests. A Limrun credential fingerprint now hashes the exact variable values, so padding whitespace around a value counts as a change, as it does for BrowserStack.
- A partial Limrun instance set now appears in `daemon status` as a failed provider and as `provider_plugin_failed` in `daemon.log`, replacing `provider_runtime_skipped`.
- 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`, `details.registeredProviders`, and `details.failedPlugins` naming a plugin that failed to register. 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.
Expand Down
72 changes: 72 additions & 0 deletions docs/adr/0034-provider-plugin-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 0034. Provider plugin registry

Status: accepted. Umbrella #3747; contract and registry #3755.

## Rules at a glance

- One contract. A provider plugin is a factory that returns `{ apiVersion: 2, provider, credentials,
connection?, setup(host) }`. `setup` returns `{ runtime, platformModule, dispose? }` or
`{ webDriver, dispose? }`. A `{ webDriver }` result stays a data descriptor the host interprets.
- One registry. Sources compose in the order built-in, store, embedder; the embedder slot arrives with #3757. Built-in providers use the
same shape as an installed plugin, and one function validates what every factory returns.
- Plugins import only types from agent-device. The type-only contract is `agent-device/plugins`;
there is no runtime helper, and the contract does not depend on a plugin's module URL.
- The first registration of a provider id claims it, even when that registration fails. Later ones
are recorded as failed. `cloud` and `proxy` are routing identities no plugin can claim.
- A plugin that fails to enumerate, load, validate or set up is recorded and skipped. The daemon
starts with the rest. The inventory lists every plugin as `active` or `failed`, with a typed
`error.reason`, and is published in `daemon.json` and by `agent-device daemon status`.
- `registry.close()` is the single, idempotent cleanup owner. It shuts down each runtime, then calls
its `dispose`, and returns an outcome per plugin and step. Runtimes are never wrapped.
Composition is transactional: when a later step fails, it closes the registry before it throws.
- The registry receives candidates. Composition chooses the sources.
- `daemon.json` has one reader. Its states are `absent`, `unreadable`, and `invalid`. A missing or
stale record reads as a stopped daemon; an unreadable or invalid one never does.
- The plugin set is fixed for a daemon's lifetime. A plugin change takes effect on the next daemon.
- v1 plugins (`agentDevicePlugin.apiVersion: 1`) without a `./agent-device` export are dispatched by
their manifest before any code runs and load through an adapter until 0.22 removes them (#3759).
A package that keeps the manifest and also exports `./agent-device` loads as v2; hosts without
v2 still read the manifest.

## Context

Three things loaded providers: a hand-kept `RESERVED_PLUGIN_PROVIDERS` list, a second list of
providers the daemon must have a runtime for, and a store that treated one bad plugin as a daemon
startup failure. Each provider kind also needed its own path for credential fingerprints and for
`connect` capabilities, which read manifests the bundled providers do not have. An embedding host
had to fake the store to load a plugin from its own dependencies (#3747).

## Decisions

- **A factory takes options.** The store calls it with `config.json` `plugins[name].options`; a JS
host calls it directly with its own, such as credentials. A plugin that takes credentials as
options declares no environment variables for them, so they stay out of the environment
fingerprint.
- **A package exports its entry as `./agent-device`.** The host resolves it with import
conditions and falls back to the package root only when the subpath is absent. A subpath whose
import or setup throws never falls back. A v2 package needs no manifest.
- **The registry owns identity, not the retired lists.** Duplicate ids and bundled ids are one
question answered by claim order. Credential variables, per-backend narrowing and connect
capabilities are read from the registry's plugins, not from manifests. A v1 manifest still
supplies them for a v1 plugin.
- **Failure is data.** A failed plugin is an inventory entry, not an exception. Behavior keys on
`error.reason`, never on the message.
- **A client reads a daemon's inventory from `daemon.json`.** The client credential fingerprint
takes variable names from it and falls back to v1 manifests. A v2 plugin declares its connect
capabilities in code, so a client loads the store, without setting up a runtime, before it reads
them.

## Daemon ownership

The target is that starting or replacing a daemon is an explicit act, not a side effect of a client
call. Today a client call can still start a daemon; #3754 makes that explicit for the client
lifecycle, and #3757 adds `agent-device/host`, where an embedder owns its daemon and passes its
plugins as the third source.

## Alternatives refused

- **Keep the manifest as the contract.** It cannot describe options-dependent credentials, and a
bundled provider has no package to hold one.
- **A runtime helper such as `definePlugin`.** It would make plugins depend on a runtime export of
agent-device and its version, which is what a type-only contract avoids.
- **Fail the daemon on a bad plugin.** One broken optional package then takes every provider down.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
| [0031 macOS Native App Backend](0031-macos-native-app-backend.md) | `AGENT_DEVICE_MACOS_APP_BACKEND`, driving macOS app sessions without XCTest Automation Mode, why pointer actions are accessibility actions only, and the ghost cursor |
| [0032 Layering Graph Engine](0032-layering-graph-engine.md) | evaluating replacement of `scripts/layering/` graph rules with dependency-cruiser or fallow `boundaries`, why graph-based layering checks share one replaceable import model, the measured edge-set and parity gaps, and the revisit triggers |
| [0033 Collocation decision table](0033-collocation-decision-table.md) | moving or adding a file listed in umbrella #3276 §5, re-proposing a move that table refused, reading a zone's collocation decisions |
| [0034 Provider plugin registry](0034-provider-plugin-registry.md) | provider plugins, the `agent-device/plugins` contract, plugin sources and order, duplicate provider ids, plugin failure isolation, the daemon plugin inventory and `daemon status` |

ADRs record *why*; the registries and gates they describe are the living source of truth — when
prose and a registry disagree, the registry wins and the ADR needs a follow-up.
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/build-an-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Entry points for integrations:
- `agent-device/plugins/webdriver`
- experimental `WebDriverPluginOptions` for providers using the shared engine.
- `agent-device/plugins`
- experimental factory context: `ProviderPluginHost`; see [provider plugins](./plugins.md).
- experimental plugin contract: `AgentDevicePluginFactory` and `ProviderPluginHost`; see [provider plugins](./plugins.md).

## Android ADB providers

Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/limrun.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ An agent in a sandbox can drive an instance that someone else created, such as t
| iOS | `LIM_IOS_INSTANCE_URL` (`status.apiUrl`), `LIM_IOS_INSTANCE_TOKEN` (`status.token`) |
| Android | `LIM_ANDROID_INSTANCE_URL` (`status.apiUrl`), `LIM_ANDROID_INSTANCE_TOKEN` (`status.token`), `LIM_ANDROID_INSTANCE_ADB_URL` (`status.adbWebSocketUrl`) |

The `lim` CLI reads the same variables, so one set works for both tools. Set all of a platform's variables or none. With a partial set, `connect` fails, and the daemon disables Limrun and records `provider_runtime_skipped` in `daemon.log`.
The `lim` CLI reads the same variables, so one set works for both tools. Set all of a platform's variables or none. With a partial set, `connect` fails, and the daemon registers without Limrun, lists it as `failed` in `agent-device daemon status`, and records `provider_plugin_failed` in `daemon.log`.

```bash
export LIM_IOS_INSTANCE_URL=...
Expand Down
Loading
Loading