diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e0e9a9dc..f4776c98b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - 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 `. - 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 `. The takeover notice moves from `Replacing daemon … : ` 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 `. 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. +- `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. - Add `uninstall ` 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. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e103d8532c..115f141423 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -300,6 +300,10 @@ "types": "./src/lease-scope.ts", "default": "./src/lease-scope.ts" }, + "./plugin-inventory": { + "types": "./src/plugin-inventory.ts", + "default": "./src/plugin-inventory.ts" + }, "./local-interactor-operation-set": { "types": "./src/local-interactor-operation-set.ts", "default": "./src/local-interactor-operation-set.ts" diff --git a/packages/contracts/src/plugin-inventory.test.ts b/packages/contracts/src/plugin-inventory.test.ts new file mode 100644 index 0000000000..48a1b4a199 --- /dev/null +++ b/packages/contracts/src/plugin-inventory.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest'; +import { pluginCredentialVariables, readPluginInventory } from './plugin-inventory.ts'; + +const credentials = { variables: ['A'], byBackend: { ios: ['A', 'B'] } }; + +test('the reader keeps well-formed entries and drops entries that lose plugin state', () => { + const active = { provider: 'p', source: 'store', state: 'active', credentials }; + const failed = { + source: 'builtin', + state: 'failed', + error: { code: 'INVALID_ARGS', message: 'bad', reason: 'invalid_plugin' }, + credentials, + }; + expect( + readPluginInventory([ + active, + failed, + { ...active, credentials: undefined }, + { ...active, credentials: { variables: 'A' } }, + { ...failed, error: undefined }, + { ...active, state: 'unknown' }, + 'not an entry', + ]), + ).toEqual([active, failed]); +}); + +test('credential variables narrow to the lease backend of an active plugin only', () => { + const inventory = readPluginInventory([ + { provider: 'p', source: 'store', state: 'active', credentials }, + { + provider: 'q', + source: 'store', + state: 'failed', + error: { code: 'X', message: 'm' }, + credentials, + }, + ]); + expect(pluginCredentialVariables(inventory, 'p', 'ios')).toEqual(['A', 'B']); + expect(pluginCredentialVariables(inventory, 'p', 'android')).toEqual(['A']); + expect(pluginCredentialVariables(inventory, 'q')).toBeUndefined(); +}); diff --git a/packages/contracts/src/plugin-inventory.ts b/packages/contracts/src/plugin-inventory.ts new file mode 100644 index 0000000000..77e68c6fbd --- /dev/null +++ b/packages/contracts/src/plugin-inventory.ts @@ -0,0 +1,125 @@ +import { isRecord } from '@agent-device/kernel/record'; + +export type PluginSource = 'builtin' | 'store' | 'embedder'; + +export type PluginInventoryError = Readonly<{ + code: string; + message: string; + reason?: string; + hint?: string; +}>; + +export type PluginInventoryCredentials = Readonly<{ + variables: readonly string[]; + byBackend: Readonly>; +}>; + +type PluginInventoryIdentity = Readonly<{ + provider?: string; + source: PluginSource; + package?: string; + version?: string; + credentials: PluginInventoryCredentials; +}>; + +/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */ +export type PluginInventoryEntry = + | (PluginInventoryIdentity & Readonly<{ state: 'active'; error?: undefined }>) + | (PluginInventoryIdentity & Readonly<{ state: 'failed'; error: PluginInventoryError }>); + +/** One step of a plugin's cleanup: it completed, or it threw this typed error. */ +export type PluginCleanupStep = + | Readonly<{ status: 'ok' }> + | Readonly<{ status: 'failed'; error: PluginInventoryError }>; + +/** + * What closing the registry did for one registered plugin. A plugin without `dispose` has nothing to + * dispose, which reads as `ok`. + */ +export type PluginCleanupOutcome = Readonly<{ + provider: string; + source: PluginSource; + shutdown: PluginCleanupStep; + dispose: PluginCleanupStep; +}>; + +export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({ + variables: Object.freeze([]), + byBackend: Object.freeze({}), +}); + +/** + * The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own + * list when the plugin declares one, otherwise its flat list. Undefined when no active plugin + * claims the provider. + */ +export function pluginCredentialVariables( + inventory: readonly PluginInventoryEntry[], + provider: string, + leaseBackend?: string, +): readonly string[] | undefined { + const entry = inventory.find((candidate) => candidate.provider === provider); + if (entry?.state !== 'active') return undefined; + const { variables, byBackend } = entry.credentials; + return ( + (leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend) + ? byBackend[leaseBackend] + : undefined) ?? variables + ); +} + +/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */ +export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] { + return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : []; +} + +function readEntry(value: unknown): PluginInventoryEntry | undefined { + if (!isRecord(value) || !isSource(value.source)) return undefined; + const credentials = readCredentials(value.credentials); + if (!credentials) return undefined; + const identity = { + ...(typeof value.provider === 'string' ? { provider: value.provider } : {}), + source: value.source, + ...(typeof value.package === 'string' ? { package: value.package } : {}), + ...(typeof value.version === 'string' ? { version: value.version } : {}), + credentials, + }; + if (value.state === 'active') return { ...identity, state: 'active' }; + const error = readError(value.error); + return value.state === 'failed' && error ? { ...identity, state: 'failed', error } : undefined; +} + +function readCredentials(value: unknown): PluginInventoryCredentials | undefined { + if (!isRecord(value)) return undefined; + const variables = readNames(value.variables); + const byBackend = value.byBackend === undefined ? {} : value.byBackend; + if (!variables || !isRecord(byBackend)) return undefined; + const entries = Object.entries(byBackend).map(([backend, names]) => [backend, readNames(names)]); + if (entries.some(([, names]) => names === undefined)) return undefined; + return { + variables, + byBackend: Object.fromEntries(entries) as Record, + }; +} + +function isSource(value: unknown): value is PluginSource { + return value === 'builtin' || value === 'store' || value === 'embedder'; +} + +function readNames(value: unknown): readonly string[] | undefined { + return Array.isArray(value) && value.every((name) => typeof name === 'string') + ? value + : undefined; +} + +function readError(value: unknown): PluginInventoryError | undefined { + if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') { + return undefined; + } + return { + code: value.code, + message: value.message, + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + ...(typeof value.hint === 'string' ? { hint: value.hint } : {}), + }; +} diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index 55d1833059..893f7462d7 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -22,6 +22,7 @@ import { type ProcessLockAcquisition, } from '@agent-device/host-kit/file'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { @@ -54,6 +55,8 @@ export type DaemonRegistrationFields = Readonly<{ policyDigest?: string; /** What this daemon can do beyond the base RPC contract; absent from daemons that predate it. */ capabilities?: readonly DaemonCapability[]; + /** The provider plugin inventory, so a client can read what this daemon registered. */ + plugins?: readonly PluginInventoryEntry[]; }>; type DaemonRegistrationRemoval = | Readonly<{ state: 'removed' }> diff --git a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts index 3767db910c..7827d2c628 100644 --- a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts +++ b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts @@ -66,8 +66,8 @@ export function daemonRuntimeDepsFixture( }), hostDiagnostics: hostDiagnosticsFixture(), ownerLifecycle: platformOwnerLifecycleFixture(), - providerDeviceRuntimes: [], - skippedProviderRuntimes: [], + shutdownProviderRuntimes: async () => [], + pluginInventory: [], providerRuntimeIds: [], recoverableProviderIds: [], deviceRuntimeGateway: Object.freeze({ diff --git a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts index c6aa03f5c4..32127d020f 100644 --- a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration, @@ -65,14 +64,13 @@ test('daemon startup installs the interactor resolution the composition supplied } }); -test('a daemon attempt losing the lock shuts down every constructed provider', async () => { +test('a daemon attempt losing the lock shuts the providers down through the composition', async () => { const stateDir = mkdtempForTestSync('daemon-held-lock-'); const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); if (held.status !== 'acquired') throw new Error('registration fixture refused'); - const shutdown = vi.fn(() => { + const shutdownProviderRuntimes = vi.fn(async () => { throw new Error('cleanup failed'); }); - const otherShutdown = vi.fn(async () => {}); const exit = vi.fn(); const runtime = await startDaemonRuntime({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, @@ -81,19 +79,11 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a stderr: { write: () => {} }, stdout: { write: () => {} }, composeDeps: daemonRuntimeDepsFixture({ - providerDeviceRuntimes: [shutdown, otherShutdown].map( - (stop, index) => - ({ - provider: `fixture-${index}`, - shutdown: stop, - leaseLifecycle: {}, - }) as unknown as ProviderDeviceRuntime, - ), + shutdownProviderRuntimes, }), }); expect(runtime).toBeNull(); - expect(shutdown).toHaveBeenCalledOnce(); - expect(otherShutdown).toHaveBeenCalledOnce(); + expect(shutdownProviderRuntimes).toHaveBeenCalledOnce(); expect(exit).toHaveBeenCalledWith(DAEMON_STARTUP_EXIT_CODES.busy); await held.owner.finish(); }); diff --git a/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts index 0e897bb4af..fe9955f61c 100644 --- a/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts @@ -1,3 +1,7 @@ +import type { + PluginCleanupOutcome, + PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; @@ -6,7 +10,8 @@ import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/tes const lifecycleEvents = vi.hoisted(() => [] as string[]); const startupFailure = vi.hoisted(() => ({ active: false })); const providerComposition = vi.hoisted(() => ({ - skipped: [] as Array<{ provider: string; error: Error & { code: string } }>, + inventory: [] as PluginInventoryEntry[], + cleanup: [] as PluginCleanupOutcome[], })); import { @@ -42,7 +47,8 @@ function startRuntime(stateDir: string, exit: (code: number) => void) { lifecycleEvents.push('gateway-shutdown'); }, }), - skippedProviderRuntimes: providerComposition.skipped, + pluginInventory: providerComposition.inventory, + shutdownProviderRuntimes: async () => providerComposition.cleanup, })), env: { ...process.env, @@ -75,7 +81,8 @@ function publishSuccessor(paths: DaemonPaths): void { afterEach(() => { startupFailure.active = false; lifecycleEvents.length = 0; - providerComposition.skipped = []; + providerComposition.inventory = []; + providerComposition.cleanup = []; vi.restoreAllMocks(); }); @@ -147,16 +154,26 @@ test('a shutdown removes its own metadata and reports an unverified release', as } }); -test('a skipped provider runtime is logged after this daemon publishes its record', async () => { +test('a failed provider plugin is logged and published after this daemon publishes its record', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-'); - const error = Object.assign(new Error('Limrun instance access is missing X.'), { - code: 'INVALID_ARGS', - }); - providerComposition.skipped = [{ provider: 'limrun', error }]; + const failed = { + provider: 'limrun', + source: 'builtin', + state: 'failed', + error: { code: 'INVALID_ARGS', message: 'Limrun instance access is missing X.' }, + credentials: { variables: [], byBackend: {} }, + } as const; + providerComposition.inventory = [failed]; const runtime = await startRuntime(stateDir, () => {}); try { - const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped'); - expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' }); + const event = logEvents(stateDir).find(({ phase }) => phase === 'provider_plugin_failed'); + expect(event?.data).toMatchObject({ + provider: 'limrun', + source: 'builtin', + code: 'INVALID_ARGS', + }); + const published = JSON.parse(fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8')); + expect(published.plugins).toEqual([failed]); } finally { await runtime?.shutdown(); } @@ -253,3 +270,36 @@ test('both exits tear the watch down before they touch daemon.json', () => { expect(finishes[0]).toBeLessThan(stops[1]!); expect(finishes[1]).toBeGreaterThan(stops[1]!); }); + +test('a plugin whose cleanup failed at shutdown is recorded in daemon.log', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-provider-cleanup-'); + providerComposition.cleanup = [ + { + provider: 'example', + source: 'store', + shutdown: { status: 'failed', error: { code: 'COMMAND_FAILED', message: 'shutdown broke' } }, + dispose: { status: 'ok' }, + }, + { + provider: 'healthy', + source: 'builtin', + shutdown: { status: 'ok' }, + dispose: { status: 'ok' }, + }, + ]; + const runtime = await startRuntime(stateDir, () => {}); + try { + await runtime?.shutdown(); + const events = logEvents(stateDir).filter( + ({ phase }) => phase === 'provider_plugin_cleanup_failed', + ); + expect(events).toHaveLength(1); + expect(events[0]?.data).toMatchObject({ + provider: 'example', + source: 'store', + shutdown: { code: 'COMMAND_FAILED' }, + }); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 73278483ef..162829aced 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -96,10 +96,13 @@ import type { PlatformOwnerLifecycle } from '../../core/platform-owner-lifecycle import type { PlatformResourceCleanup } from '../../core/platform-resource-cleanup.ts'; import type { LeaseLifecycleProvider, - ProviderDeviceRuntime, ProviderExpiredLeaseRecovery, } from '@agent-device/contracts/device'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; +import type { + PluginCleanupOutcome, + PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000; const LEASE_EXPIRY_SWEEP_INTERVAL_MS = 1_000; @@ -127,13 +130,18 @@ export type DaemonRuntimeDeps = Readonly<{ ownedProcessRecords: OwnedProcessRecordStore; hostDiagnostics: HostDiagnostics; ownerLifecycle: PlatformOwnerLifecycle; - providerDeviceRuntimes: readonly ProviderDeviceRuntime[]; /** - * Provider runtimes the composition skipped. Data only: this server records each one as a - * `provider_runtime_skipped` diagnostic after publishing its registration, so log emission - * stays in this module. + * Shuts every provider down, each runtime and then its plugin's own cleanup. The composition owns + * what a provider is made of, so the server calls this once per shutdown path and never reaches + * into a runtime. + */ + shutdownProviderRuntimes: () => Promise; + /** + * Every provider plugin the composition saw, active or failed. Data only: this server publishes + * it in the daemon registration and records each failed entry as a `provider_plugin_failed` + * diagnostic, so log emission stays in this module. */ - skippedProviderRuntimes: readonly Readonly<{ provider: string; error: AppError }>[]; + pluginInventory: readonly PluginInventoryEntry[]; interactorResolution: InteractorResolution; providerDeviceAdmission: ProviderDeviceAdmission; cleanupManagedWebOrphans: ( @@ -443,8 +451,26 @@ export async function startDaemonRuntime( const daemonIdentity = readCurrentOwnerIdentity(); const daemonCodeOrigin = resolveDaemonCodeOrigin(); const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = deps.providerDeviceRuntimes; const deviceRuntimeGateway = deps.deviceRuntimeGateway; + // Provider cleanup is best effort for the daemon, but a plugin whose cleanup failed is recorded. + // A daemon that lost the registration does not own `daemon.log`, so it reports on stderr instead. + const closeProviders = async (report: 'daemon-log' | 'stderr'): Promise => { + const outcomes = await deps.shutdownProviderRuntimes().catch(() => []); + for (const { provider, source, shutdown, dispose } of outcomes) { + if (shutdown.status === 'ok' && dispose.status === 'ok') continue; + const data = { + provider, + source, + shutdown: shutdown.status === 'failed' ? shutdown.error : undefined, + dispose: dispose.status === 'failed' ? dispose.error : undefined, + }; + if (report === 'daemon-log') { + await emitDaemonDiagnostic(logPath, 'provider_plugin_cleanup_failed', data); + } else { + stderr.write(`Provider plugin cleanup failed: ${JSON.stringify(data)}\n`); + } + } + }; const applicationLifecycle = deviceRuntimeGateway.applicationLifecycle; if (!applicationLifecycle) { throw new AppError('COMMAND_FAILED', 'Platform lifecycle gateway is not configured.', { @@ -497,6 +523,7 @@ export async function startDaemonRuntime( platformResourceCleanup: deps.platformResourceCleanup, leaseLifecycleProvider: deps.leaseLifecycleProvider, providerRuntimeIds: deps.providerRuntimeIds, + failedPlugins: deps.pluginInventory.filter(({ state }) => state === 'failed'), ...deps.requestRouter, }); @@ -726,6 +753,7 @@ export async function startDaemonRuntime( codeSignature: daemonCodeSignature, policyDigest: daemonPolicy?.digest, capabilities: DAEMON_CAPABILITIES, + plugins: deps.pluginInventory, }); if (socketPort) stdout.write(`AGENT_DEVICE_DAEMON_PORT=${socketPort}\n`); if (httpPort) stdout.write(`AGENT_DEVICE_DAEMON_HTTP_PORT=${httpPort}\n`); @@ -741,9 +769,7 @@ export async function startDaemonRuntime( const acquisition = await tryAcquireDaemonRegistration(daemonPaths); if (acquisition.status !== 'acquired') { - await Promise.allSettled( - providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), - ); + await closeProviders('stderr'); stderr.write(`Daemon registration ${acquisition.status}; exiting.\n`); exit(DAEMON_STARTUP_EXIT_CODES[acquisition.status]); return null; @@ -811,12 +837,15 @@ export async function startDaemonRuntime( publishDaemonInfo(socketPort, httpPort); stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity); await flushDaemonStartupDiagnostics(logPath, startupDiagnostics); - for (const skipped of deps.skippedProviderRuntimes) { - await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', { - provider: skipped.provider, - code: skipped.error.code, - message: skipped.error.message, - hint: skipped.error.details?.hint, + for (const plugin of deps.pluginInventory.filter(({ state }) => state === 'failed')) { + await emitDaemonDiagnostic(logPath, 'provider_plugin_failed', { + provider: plugin.provider, + package: plugin.package, + source: plugin.source, + code: plugin.error?.code, + reason: plugin.error?.reason, + message: plugin.error?.message, + hint: plugin.error?.hint, }); } // After publication: publishDaemonInfo truncates daemon.log, so anything @@ -839,9 +868,7 @@ export async function startDaemonRuntime( stderr.write(`Daemon error: ${appErr.message}\n`); closeServersBestEffort(servers); stopMetadataLossWatch(); - await Promise.allSettled( - providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), - ); + await closeProviders('daemon-log'); await finishDaemonRegistration({ registration, infoPath, logPath }); await deps.ownerLifecycle.clearDaemonLockConfiguration(); exit(1); @@ -912,9 +939,7 @@ export async function startDaemonRuntime( }); expiredProviderLeaseReleaser.shutdown(); await deviceRuntimeGateway.shutdown(); - await Promise.allSettled( - providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), - ); + await closeProviders('daemon-log'); await applicationLifecycle.finalizeDaemonShutdown(); // Best effort: stop the PNG worker so an in-flight job cannot delay exit. await Promise.race([ diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index 8f8222bea0..7f1cc96145 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -1,3 +1,4 @@ +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import { createOwnerScopedDeviceClaimReconciler, type ClaimRecoveryGatewayFactory, @@ -130,6 +131,7 @@ export type RequestRouterDeps = { screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; hostDiagnostics?: HostDiagnostics; providerRuntimeIds?: readonly string[]; + failedPlugins?: readonly PluginInventoryEntry[]; providerCredentials: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; @@ -160,6 +162,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(), hostDiagnostics, providerRuntimeIds, + failedPlugins, providerCredentials, leaseLifecycleProvider, cloudArtifactProvider, @@ -349,6 +352,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, leaseLifecycleProvider, providerRuntimeIds, + failedPlugins, providerCredentials, cloudArtifactProvider, endSessionsForLease, diff --git a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts index edcf3a33d7..cc48cd05dc 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts @@ -1,3 +1,4 @@ +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { handleLeaseCommands } from '../lease.ts'; @@ -648,6 +649,59 @@ test('lease_allocate refuses a provider no registered runtime claims, before rec assert.deepEqual(registry.listActiveLeases(), []); }); +test('lease_allocate names the plugin that failed to register when it could have backed the provider', async () => { + const registry = new LeaseRegistry(); + const failed = (provider: string | undefined, packageName: string): PluginInventoryEntry => ({ + ...(provider === undefined ? {} : { provider }), + source: 'store', + package: packageName, + state: 'failed', + error: { code: 'INVALID_ARGS', message: `${packageName} is broken`, reason: 'invalid_plugin' }, + credentials: { variables: [], byBackend: {} }, + }); + 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-failed-plugin-provider-'), + leaseRegistry: registry, + providerRuntimeIds: ['browserstack'], + failedPlugins: [ + failed('testmu', '@agent-device/testmu'), + failed(undefined, 'unloadable-plugin'), + failed('other', 'other-plugin'), + ], + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'provider-not-registered'); + assert.deepEqual(error.details?.failedPlugins, [ + { + provider: 'testmu', + package: '@agent-device/testmu', + code: 'INVALID_ARGS', + reason: 'invalid_plugin', + message: '@agent-device/testmu is broken', + }, + { + package: 'unloadable-plugin', + code: 'INVALID_ARGS', + reason: 'invalid_plugin', + message: 'unloadable-plugin is broken', + }, + ]); + 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({ diff --git a/packages/daemon/src/sessions/handlers/lease.ts b/packages/daemon/src/sessions/handlers/lease.ts index 6b12419448..244f7fabdb 100644 --- a/packages/daemon/src/sessions/handlers/lease.ts +++ b/packages/daemon/src/sessions/handlers/lease.ts @@ -1,3 +1,4 @@ +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DeviceLease, @@ -47,6 +48,7 @@ type LeaseHandlerArgs = { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; + failedPlugins?: readonly PluginInventoryEntry[]; providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; @@ -60,6 +62,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise + plugin.state === 'failed' && (plugin.provider === provider || plugin.provider === undefined) + ? [ + { + ...(plugin.provider === undefined ? {} : { provider: plugin.provider }), + ...(plugin.package === undefined ? {} : { package: plugin.package }), + code: plugin.error.code, + ...(plugin.error.reason === undefined ? {} : { reason: plugin.error.reason }), + message: plugin.error.message, + }, + ] + : [], + ); throw new AppError( 'UNSUPPORTED_OPERATION', `Provider "${provider}" is not available in this daemon runtime.`, @@ -346,7 +364,11 @@ function assertProviderRuntimeRegistered( reason: 'provider-not-registered', provider, registeredProviders: [...registeredProviders], - hint: `Restart the daemon with ${provider} configured, then retry lease allocation.`, + ...(failures.length > 0 ? { failedPlugins: failures } : {}), + hint: + failures.length > 0 + ? `Fix the plugin that failed to register (see failedPlugins or agent-device daemon status), restart the daemon, then retry lease allocation.` + : `Restart the daemon with ${provider} configured, then retry lease allocation.`, }, ); } diff --git a/packages/daemon/src/sessions/request-handler-chain.ts b/packages/daemon/src/sessions/request-handler-chain.ts index 549fb082b5..11d240e844 100644 --- a/packages/daemon/src/sessions/request-handler-chain.ts +++ b/packages/daemon/src/sessions/request-handler-chain.ts @@ -1,3 +1,4 @@ +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../core/platform-services.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; @@ -36,6 +37,7 @@ type RequestHandlerChainParams = { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; + failedPlugins?: readonly PluginInventoryEntry[]; providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; @@ -156,6 +158,7 @@ async function runLeaseHandler( sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, providerRuntimeIds: params.providerRuntimeIds, + failedPlugins: params.failedPlugins, providerCredentials: params.providerCredentials, leaseLifecycleProvider: params.leaseLifecycleProvider, cloudArtifactProvider: params.cloudArtifactProvider, diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 161c940f36..610f873555 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -14,14 +14,30 @@ "types": "./src/connection.ts", "default": "./src/connection.ts" }, + "./contract": { + "types": "./src/contract.ts", + "default": "./src/contract.ts" + }, "./host": { "types": "./src/host.ts", "default": "./src/host.ts" }, + "./inventory": { + "types": "./src/inventory.ts", + "default": "./src/inventory.ts" + }, "./load": { "types": "./src/load.ts", "default": "./src/load.ts" }, + "./manifest": { + "types": "./src/manifest.ts", + "default": "./src/manifest.ts" + }, + "./registry": { + "types": "./src/registry.ts", + "default": "./src/registry.ts" + }, "./store": { "types": "./src/store.ts", "default": "./src/store.ts" diff --git a/packages/plugins/src/connection.ts b/packages/plugins/src/connection.ts index 3f581f8607..1bedc4b079 100644 --- a/packages/plugins/src/connection.ts +++ b/packages/plugins/src/connection.ts @@ -1,34 +1,79 @@ -import type { CliFlags } from '@agent-device/contracts/command'; -import type { - ConnectionProviderCapabilities, - ProviderConnectionVerification, - RemoteConfigProfile, -} from '@agent-device/contracts/remote'; -import type { EnvMap } from '@agent-device/kernel/source-value'; -import { installedPlugins } from './store.ts'; - -export type PluginConnection = Readonly<{ - resolve(context: { flags: CliFlags; stateDir: string; cwd: string; env: EnvMap }): - | Promise<{ - profile: RemoteConfigProfile; - extraFlags?: Partial; - }> - | { profile: RemoteConfigProfile; extraFlags?: Partial }; - verify(context: { flags: CliFlags; env: EnvMap }): Promise; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; +import { resolveUserConfigPath } from '@agent-device/host-kit/file'; +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; +import type { PluginConnection, PluginConnectionContext } from './contract.ts'; +import { legacyStoreManifests, storePluginCandidates } from './store.ts'; + +/** A plugin's connection as the client calls it: the registry adds the `host` to each context. */ +export type PluginConnectionCallbacks = Readonly<{ + resolve(context: Omit): ReturnType; + verify( + context: Omit, 'host'>, + ): ReturnType; }>; +export type RegisteredConnection = PluginConnectionCallbacks & + Readonly<{ capabilities: PluginConnection['capabilities'] }>; + +// Capabilities live in the plugin, so a v2 store plugin answers only once its package has loaded. +// A client loads the store once per process through primePluginConnections; every synchronous +// reader below then answers from that registry alone. Before the store is primed, a v1 plugin +// answers from its manifest. +export type FailedPlugin = Extract; + +type PrimedStore = Readonly<{ + connections: ReadonlyMap; + failures: readonly FailedPlugin[]; +}>; +const primed = new Map(); + +/** Loads the store's plugins into the connection registry this process reads capabilities from. */ +export async function primePluginConnections( + env: NodeJS.ProcessEnv = process.env, + builtinProviders: readonly string[] = [], +): Promise { + if (primed.has(resolveUserConfigPath(env))) return; + const { claimProviderIds, resolveProviderPlugins } = await import('./registry.ts'); + const resolved = await resolveProviderPlugins([ + ...claimProviderIds(builtinProviders), + ...(await storePluginCandidates(env)), + ]); + primed.set(resolveUserConfigPath(env), { + connections: resolved.connections, + failures: resolved + .inventory() + .filter((entry): entry is FailedPlugin => entry.state === 'failed'), + }); +} + +/** The primed connection of a store plugin, or undefined before priming or for an unknown provider. */ +export function primedPluginConnection( + provider: string, + env: NodeJS.ProcessEnv = process.env, +): RegisteredConnection | undefined { + return primed.get(resolveUserConfigPath(env))?.connections.get(provider); +} + +/** The store plugins that failed to load, as the primed registry saw them. */ +export function pluginLoadFailures(env: NodeJS.ProcessEnv = process.env): readonly FailedPlugin[] { + return primed.get(resolveUserConfigPath(env))?.failures ?? []; +} + export function pluginConnectionCapabilities( provider: string | undefined, env: NodeJS.ProcessEnv = process.env, ): ConnectionProviderCapabilities | undefined { - return provider === undefined - ? undefined - : installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) - ?.agentDevicePlugin.connection; + if (provider === undefined) return undefined; + const store = primed.get(resolveUserConfigPath(env)); + if (store) return store.connections.get(provider)?.capabilities; + return legacyStoreManifests(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) + ?.agentDevicePlugin.connection; } export function pluginConnectionNames(env: NodeJS.ProcessEnv = process.env): string[] { - return installedPlugins(env) + const store = primed.get(resolveUserConfigPath(env)); + if (store) return [...store.connections.keys()]; + return legacyStoreManifests(env) .filter((plugin) => plugin.agentDevicePlugin.connection) .map((plugin) => plugin.agentDevicePlugin.provider); } diff --git a/packages/plugins/src/contract.ts b/packages/plugins/src/contract.ts new file mode 100644 index 0000000000..956645453a --- /dev/null +++ b/packages/plugins/src/contract.ts @@ -0,0 +1,109 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import type { + ConnectionProviderCapabilities, + ProviderConnectionVerification, + RemoteConfigProfile, +} from '@agent-device/contracts/remote'; +import type { ProviderPluginHost } from './host.ts'; + +/** The provider plugin contract version this module defines. */ +export const PROVIDER_PLUGIN_API_VERSION = 2; + +/** + * The environment variables a plugin reads credentials from. The host fingerprints their values to + * refuse a lease when the shell holds different credentials than the daemon started with. List + * nothing for credentials the embedder passes as factory options. + */ +export type PluginCredentials = Readonly<{ + variables: readonly string[]; + /** Narrows `variables` to what a lease on one backend reads, such as `android-instance`. */ + byBackend?: Readonly>; +}>; + +export type PluginConnectionContext = Readonly<{ + flags: CliFlags; + stateDir: string; + cwd: string; + env: Readonly>; + /** The same host `setup` receives, built from this call's `env`; no runtime is set up. */ + host: ProviderPluginHost; +}>; + +export type PluginConnectionResolution = { + profile: RemoteConfigProfile; + extraFlags?: Partial; +}; + +/** What `agent-device connect ` asks the plugin; the host owns the connection state. */ +export type PluginConnection = Readonly<{ + /** Local provider capabilities; `requiresRemoteDaemon` must be false. */ + capabilities: ConnectionProviderCapabilities; + resolve( + context: PluginConnectionContext, + ): Promise | PluginConnectionResolution; + verify( + context: Pick, + ): Promise; +}>; + +/** + * The provider runtime a plugin builds. This type names only `provider` and `shutdown`; the host + * also requires `leaseLifecycle`, `ownsDevice`, `getInteractor` and `deviceInventoryProvider` from + * the provider runtime contract before it registers the runtime. + */ +export type PluginProviderRuntime = Readonly<{ + provider: string; + shutdown(): Promise; +}>; + +export type PluginPlatformModule = Readonly<{ + owner: Readonly<{ kind: 'provider-runtime'; provider: string; instance: string }>; + loadRuntime(...args: never[]): Promise; +}>; + +/** A provider runtime the plugin builds itself. */ +export type PluginRuntimeSetup = Readonly<{ + runtime: PluginProviderRuntime; + platformModule: PluginPlatformModule; + /** Called once when the daemon shuts the plugin down, after `runtime.shutdown()`. */ + dispose?(): Promise | void; +}>; + +/** What the registry reads of a WebDriver descriptor; the composing root owns the rest. */ +export type WebDriverDescriptor = Readonly<{ provider: string }>; + +/** + * A hosted WebDriver service. The host interprets the descriptor and builds the runtime; the + * descriptor's type belongs to the composing root, so this package never names a provider. + */ +export type PluginWebDriverSetup = + Readonly<{ + webDriver: WebDriver; + dispose?(): Promise | void; + }>; + +export type PluginSetupResult = + | PluginRuntimeSetup + | PluginWebDriverSetup; + +export type AgentDevicePlugin = + Readonly<{ + apiVersion: typeof PROVIDER_PLUGIN_API_VERSION; + /** Lowercase id; `cloud` and `proxy` are reserved routing identities. */ + provider: string; + credentials: PluginCredentials; + connection?: PluginConnection; + /** Builds the runtime. Keep it free of network I/O and device allocation. */ + setup( + host: ProviderPluginHost, + ): Promise> | PluginSetupResult; + }>; + +/** + * The default export of a plugin package's `./agent-device` entry. The store calls it with + * `config.json` `plugins[name].options`; a JS host calls it directly. + */ +export type AgentDevicePluginFactory< + Options = Record, + WebDriver extends WebDriverDescriptor = WebDriverDescriptor, +> = (options?: Options) => AgentDevicePlugin | Promise>; diff --git a/packages/plugins/src/discovery.test.ts b/packages/plugins/src/discovery.test.ts new file mode 100644 index 0000000000..ff2299bb0c --- /dev/null +++ b/packages/plugins/src/discovery.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { discoverPluginEntry } from './discovery.ts'; + +type Case = { + name: string; + exports: unknown; + files: string[]; + /** What Node resolves `/agent-device` to, or undefined when Node refuses it. */ + subpathFile?: string; + /** What the host loads: the subpath's file, the root's file, or a refusal. */ + loads: string | 'refused'; +}; + +const CASES: readonly Case[] = [ + { + name: 'pattern', + exports: { './*': './*.js' }, + files: ['agent-device.js'], + subpathFile: 'agent-device.js', + loads: 'agent-device.js', + }, + { + name: 'pattern-with-trailer', + exports: { './agent-*': './impl-*.js', '.': './root.js' }, + files: ['impl-device.js', 'root.js'], + subpathFile: 'impl-device.js', + loads: 'impl-device.js', + }, + { + name: 'condition-set-to-null', + exports: { './agent-device': { node: null, default: './ok.js' }, '.': './root.js' }, + files: ['ok.js', 'root.js'], + loads: 'refused', + }, + { + name: 'no-matching-condition', + exports: { './agent-device': { require: './ok.js' }, '.': './root.js' }, + files: ['ok.js', 'root.js'], + loads: 'refused', + }, + { + name: 'subpath-absent', + exports: { '.': './root.js', './other': './other.js' }, + files: ['root.js', 'other.js'], + loads: 'root.js', + }, + { + name: 'array-skips-an-invalid-target', + exports: { './agent-device': ['not-a-relative-target', './ok.js'] }, + files: ['ok.js'], + subpathFile: 'ok.js', + loads: 'ok.js', + }, + { + name: 'conditions-in-package-order', + exports: { './agent-device': { import: './first.js', default: './second.js' } }, + files: ['first.js', 'second.js'], + subpathFile: 'first.js', + loads: 'first.js', + }, + { + name: 'root-sugar', + exports: './root.js', + files: ['root.js'], + loads: 'root.js', + }, +]; + +test('package export resolution agrees with Node for each shape a plugin package may use', () => { + const root = mkdtempForTestSync('agent-device-exports-'); + for (const entry of CASES) { + const directory = path.join(root, 'node_modules', entry.name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name: entry.name, + version: '1.0.0', + type: 'module', + exports: entry.exports, + }), + ); + for (const file of entry.files) fs.writeFileSync(path.join(directory, file), 'export {};'); + } + const nodeResolutions = JSON.parse( + execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `const out = {}; + for (const name of ${JSON.stringify(CASES.map((entry) => entry.name))}) { + try { out[name] = new URL(import.meta.resolve(name + '/agent-device')).pathname; } + catch (error) { out[name] = error.code; } + } + console.log(JSON.stringify(out));`, + ], + { cwd: root, encoding: 'utf8' }, + ), + ) as Record; + + for (const entry of CASES) { + const directory = fs.realpathSync(path.join(root, 'node_modules', entry.name)); + const node = nodeResolutions[entry.name]!; + if (entry.subpathFile) { + assert.equal(fs.realpathSync(node), path.join(directory, entry.subpathFile), entry.name); + } else { + assert.equal(node, 'ERR_PACKAGE_PATH_NOT_EXPORTED', entry.name); + } + if (entry.loads === 'refused') { + assert.throws( + () => discoverPluginEntry(directory), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === 'incompatible_plugin', + entry.name, + ); + } else { + assert.deepEqual( + discoverPluginEntry(directory), + { kind: 'v2', file: path.join(directory, entry.loads) }, + entry.name, + ); + } + } +}); diff --git a/packages/plugins/src/discovery.ts b/packages/plugins/src/discovery.ts new file mode 100644 index 0000000000..d052660a03 --- /dev/null +++ b/packages/plugins/src/discovery.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import { readPluginManifest, resolvePluginEntry, type PluginManifest } from './manifest.ts'; + +const PROVIDER_ID = /^[a-z][a-z0-9-]*$/; + +export function isProviderId(value: unknown): value is string { + return typeof value === 'string' && PROVIDER_ID.test(value); +} + +/** The subpath a v2 plugin package exports; its default export is the plugin factory. */ +const PLUGIN_ENTRY_SUBPATH = './agent-device'; + +export type PluginPackageEntry = + | Readonly<{ kind: 'v2'; file: string }> + | Readonly<{ kind: 'v1'; manifest: PluginManifest }>; + +/** + * Chooses how an installed package loads, without evaluating it. The `./agent-device` export wins; + * a package that lacks that subpath and declares `agentDevicePlugin` is a v1 plugin dispatched by + * its manifest; any other package falls back to its root export. A subpath that exists but does + * not resolve is an error, never a reason to fall back. + */ +export function discoverPluginEntry(directory: string): PluginPackageEntry { + const manifest = readPackageJson(directory); + const subpath = resolveExport(manifest.exports, PLUGIN_ENTRY_SUBPATH); + if (subpath.status === 'resolved') { + return { kind: 'v2', file: resolvePluginEntry(directory, subpath.target) }; + } + if (subpath.status === 'blocked') throw unresolvableEntry(directory, PLUGIN_ENTRY_SUBPATH); + if (manifest.agentDevicePlugin !== undefined) { + return { kind: 'v1', manifest: readPluginManifest(directory) }; + } + const root = resolveExport(manifest.exports, '.'); + if (root.status === 'blocked') throw unresolvableEntry(directory, '.'); + const target = + root.status === 'resolved' + ? root.target + : (manifest.exports === undefined || manifest.exports === null) && + typeof manifest.main === 'string' + ? `./${path.posix.normalize(manifest.main)}` + : undefined; + if (target === undefined) throw missingEntry(directory, PLUGIN_ENTRY_SUBPATH); + return { kind: 'v2', file: resolvePluginEntry(directory, target) }; +} + +export function readPackageJson(directory: string): Record { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); + if (isRecord(manifest)) return manifest; + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `Cannot read plugin manifest: ${directory}`, + { reason: 'plugin_load_failed' }, + error as Error, + ); + } + throw new AppError('INVALID_ARGS', `Cannot read plugin manifest: ${directory}`, { + reason: 'plugin_load_failed', + }); +} + +/** + * `absent`: the package declares no key for the subpath. `blocked`: it declares one that Node's + * resolver refuses, such as a condition set to `null` or no matching condition. Only `absent` + * permits a fallback. + */ +type ExportResolution = + | Readonly<{ status: 'resolved'; target: string }> + | Readonly<{ status: 'absent' }> + | Readonly<{ status: 'blocked' }>; + +/** The condition set Node applies to an `import` of a package, in no particular order. */ +const IMPORT_CONDITION_SET: ReadonlySet = new Set([ + 'node', + 'import', + 'module-sync', + 'node-addons', + 'default', +]); + +/** Node's PACKAGE_EXPORTS_RESOLVE for `import`, including subpath patterns and `null` targets. */ +function resolveExport(exportsField: unknown, subpath: string): ExportResolution { + if (exportsField === undefined || exportsField === null) return { status: 'absent' }; + // Without subpath keys the field is the package root's target. + if (!isSubpathMap(exportsField)) { + return subpath === '.' ? settle(resolveTarget(exportsField, undefined)) : { status: 'absent' }; + } + if (Object.hasOwn(exportsField, subpath) && !subpath.includes('*')) { + return settle(resolveTarget(exportsField[subpath], undefined)); + } + const match = bestPatternMatch(Object.keys(exportsField), subpath); + return match + ? settle(resolveTarget(exportsField[match.key], match.captured)) + : { status: 'absent' }; +} + +function settle(target: string | null | undefined): ExportResolution { + return typeof target === 'string' ? { status: 'resolved', target } : { status: 'blocked' }; +} + +function isSubpathMap(value: unknown): value is Record { + return isRecord(value) && Object.keys(value).some((key) => key.startsWith('.')); +} + +/** The pattern key (one `*`) with the longest prefix that matches, as Node's PATTERN_KEY_COMPARE orders them. */ +function bestPatternMatch( + keys: readonly string[], + subpath: string, +): { key: string; captured: string } | undefined { + let best: { key: string; base: string; trailer: string } | undefined; + for (const key of keys) { + const star = key.indexOf('*'); + if (star === -1 || star !== key.lastIndexOf('*')) continue; + const base = key.slice(0, star); + const trailer = key.slice(star + 1); + const matches = + subpath.startsWith(base) && + subpath !== base && + subpath.length >= key.length && + subpath.endsWith(trailer); + if (!matches) continue; + if ( + !best || + base.length > best.base.length || + (base.length === best.base.length && key.length > best.key.length) + ) { + best = { key, base, trailer }; + } + } + return best + ? { + key: best.key, + captured: subpath.slice(best.base.length, subpath.length - best.trailer.length), + } + : undefined; +} + +/** Node's PACKAGE_TARGET_RESOLVE: a string, `null` for a blocked target, or undefined for none. */ +function resolveTarget(target: unknown, captured: string | undefined): string | null | undefined { + if (typeof target === 'string') return resolveStringTarget(target, captured); + if (target === null) return null; + if (Array.isArray(target)) + return firstResolved(target.map((item) => () => resolveTarget(item, captured))); + if (!isRecord(target)) return undefined; + const matching = Object.entries(target).filter(([condition]) => + IMPORT_CONDITION_SET.has(condition), + ); + return firstResolved( + matching.map( + ([, value]) => + () => + resolveTarget(value, captured), + ), + ); +} + +function resolveStringTarget(target: string, captured: string | undefined): string | undefined { + if (!target.startsWith('./')) return undefined; + return captured === undefined ? target : target.replaceAll('*', captured); +} + +/** The first target that is not "none", which may be a `null` that blocks the rest. */ +function firstResolved( + candidates: readonly (() => string | null | undefined)[], +): string | null | undefined { + for (const candidate of candidates) { + const resolved = candidate(); + if (resolved !== undefined) return resolved; + } + return undefined; +} + +function unresolvableEntry(directory: string, subpath: string): AppError { + return new AppError('INVALID_ARGS', `Package exports ${subpath} without an import target`, { + reason: 'incompatible_plugin', + directory, + hint: `Ask the plugin author for a release whose ${subpath} export has an import condition.`, + }); +} + +function missingEntry(directory: string, subpath: string): AppError { + return new AppError('INVALID_ARGS', 'Package does not export a provider plugin', { + reason: 'incompatible_plugin', + directory, + hint: `Install a plugin release that exports ${subpath}.`, + }); +} diff --git a/packages/plugins/src/inventory.ts b/packages/plugins/src/inventory.ts new file mode 100644 index 0000000000..a67fc729a5 --- /dev/null +++ b/packages/plugins/src/inventory.ts @@ -0,0 +1,13 @@ +import { normalizeError } from '@agent-device/kernel/errors'; +import type { PluginInventoryError } from '@agent-device/contracts/plugin-inventory'; + +export function describePluginFailure(error: unknown): PluginInventoryError { + const normalized = normalizeError(error); + const reason = normalized.details?.reason; + return { + code: normalized.code, + message: normalized.message, + ...(typeof reason === 'string' ? { reason } : {}), + ...(normalized.hint ? { hint: normalized.hint } : {}), + }; +} diff --git a/packages/plugins/src/legacy.ts b/packages/plugins/src/legacy.ts new file mode 100644 index 0000000000..2a61fddea4 --- /dev/null +++ b/packages/plugins/src/legacy.ts @@ -0,0 +1,90 @@ +import { pathToFileURL } from 'node:url'; +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import { + PROVIDER_PLUGIN_API_VERSION, + type AgentDevicePlugin, + type PluginConnection, + type PluginSetupResult, +} from './contract.ts'; +import { createPluginHost, type ProviderPluginHost } from './host.ts'; +import { resolvePluginEntry, type PluginManifest } from './manifest.ts'; + +type LegacyConnection = Pick; + +/** + * Presents a v1 plugin as a v2 plugin until 0.22 removes v1. The manifest still declares the + * provider, connection capabilities and credential variables, and the v1 factory still runs with + * the host and returns `{ runtime, platformModule }` or `{ webDriver }`, so no v1 plugin changes. + */ +export function adaptLegacyPlugin( + manifest: PluginManifest, + directory: string, + options: Record | undefined, +): AgentDevicePlugin { + const declaration = manifest.agentDevicePlugin; + const runFactory = async (host: ProviderPluginHost): Promise => { + const module = await import( + pathToFileURL(resolvePluginEntry(directory, declaration.entry)).href + ); + if (typeof module.default !== 'function') + throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${manifest.name}`, { + reason: 'invalid_plugin', + }); + return await (module.default as (host: ProviderPluginHost) => unknown)(host); + }; + const withConnection = async ( + env: EnvMap, + run: (connection: LegacyConnection) => Promise | T, + ): Promise => { + const result = await runFactory(createPluginHost(env, options)); + try { + const connection = isRecord(result) ? result.connection : undefined; + if ( + !isRecord(connection) || + !isFunction(connection.resolve) || + !isFunction(connection.verify) + ) { + throw new AppError( + 'INVALID_ARGS', + `Plugin connection must provide resolve and verify: ${manifest.name}`, + { reason: 'invalid_plugin' }, + ); + } + return await run(connection as LegacyConnection); + } finally { + const runtime = isRecord(result) + ? (result.runtime as ProviderDeviceRuntime | undefined) + : undefined; + if (isFunction(runtime?.shutdown)) await Promise.allSettled([runtime.shutdown()]); + } + }; + return { + apiVersion: PROVIDER_PLUGIN_API_VERSION, + provider: declaration.provider, + credentials: { variables: declaration.credentialVariables ?? [] }, + ...(declaration.connection + ? { + connection: { + capabilities: declaration.connection, + resolve: async (context) => + await withConnection(context.env, (connection) => connection.resolve(context)), + verify: async (context) => + await withConnection(context.env, (connection) => connection.verify(context)), + }, + } + : {}), + setup: async (host) => { + const result = await runFactory(host); + return ( + isRecord(result) && 'webDriver' in result ? { webDriver: result.webDriver } : result + ) as PluginSetupResult; + }, + }; +} + +function isFunction(value: unknown): value is (...args: never[]) => unknown { + return typeof value === 'function'; +} diff --git a/packages/plugins/src/load.test.ts b/packages/plugins/src/load.test.ts index 01b80c5bb3..1da14a14eb 100644 --- a/packages/plugins/src/load.test.ts +++ b/packages/plugins/src/load.test.ts @@ -2,48 +2,235 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; +import { withPluginConnection } from './load.ts'; import { - loadProviderPlugins, - withPluginConnection, - type PluginLoadPolicy, - type ProviderPluginRegistration, -} from './load.ts'; -import { pluginHome, registrationSource, selectPlugin } from '@agent-device/test-kit'; + pluginHome, + registrationSource, + selectPlugin, + selectV2Plugin, + v2RegistrationSource, + webDriverPluginSource, +} from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import type { ProviderPluginHost } from './host.ts'; +import type { PluginRegistryPolicy } from './registry.ts'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -const policy = (reservedProviders: readonly string[] = []): PluginLoadPolicy => ({ - reservedProviders, - adaptFactoryResult: () => undefined, -}); +const adaptedDescriptors: string[] = []; +// This package names no provider: the composing root decides how a descriptor becomes a runtime. +const FAKE_POLICY: PluginRegistryPolicy = { + adaptWebDriver: async (descriptor) => { + adaptedDescriptors.push(descriptor.provider); + return { + runtime: { + provider: descriptor.provider, + ownsDevice: () => false, + getInteractor: () => undefined, + deviceInventoryProvider: async () => [], + leaseLifecycle: {}, + shutdown: async () => {}, + } as unknown as ProviderDeviceRuntime, + platformModule: { + owner: { kind: 'provider-runtime', provider: descriptor.provider, instance: 'fake' }, + loadRuntime: async () => { + throw new Error('lazy'); + }, + }, + }; + }, +}; + +async function startStore(env: NodeJS.ProcessEnv, builtin: readonly string[] = ['limrun']) { + const { claimProviderIds, resolveProviderPlugins, startProviderRegistry } = + await import('./registry.ts'); + const { storePluginCandidates } = await import('./store.ts'); + const resolved = await resolveProviderPlugins([ + ...claimProviderIds(builtin), + ...(await storePluginCandidates(env)), + ]); + return await startProviderRegistry(resolved, env, FAKE_POLICY); +} -test('startup loads the factory with options and the host error constructor', async () => { +test('a v1 store plugin loads through the adapter with options and the host error constructor', async () => { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', registrationSource('example')); - const [registration] = await loadProviderPlugins(env, policy(['limrun'])); - const runtime = registration!.runtime as ProviderDeviceRuntime & ProviderPluginHost; + const registry = await startStore(env); + const runtime = registry.registrations[0]!.runtime as ProviderDeviceRuntime & ProviderPluginHost; assert.deepEqual(runtime.options, { region: 'eu' }); assert.ok(runtime.createError('INVALID_ARGS', 'bad profile') instanceof AppError); + await registry.close(); +}); + +test('a v2 store plugin loads its factory with the selection options', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, '@example/v2', v2RegistrationSource('example')); + const registry = await startStore(env); + const runtime = registry.registrations[0]!.runtime as ProviderDeviceRuntime & { + options: unknown; + hostEnv: Record; + }; + assert.deepEqual(runtime.options, { region: 'eu' }); + assert.equal(runtime.hostEnv.AGENT_DEVICE_HOME, home); + assert.deepEqual(registry.inventory, [ + { + provider: 'example', + source: 'store', + package: '@example/v2', + version: '2.0.0', + state: 'active', + credentials: { variables: [], byBackend: {} }, + }, + ]); + await registry.close(); +}); + +test('v1 and v2 store plugins register in package-name order whichever version they are', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, 'b-plugin', v2RegistrationSource('second')); + selectPlugin(home, 'a-plugin', 'first', registrationSource('first')); + const registry = await startStore(env); + assert.deepEqual( + registry.inventory.map(({ provider, source, package: name }) => [provider, source, name]), + [ + ['first', 'store', 'a-plugin'], + ['second', 'store', 'b-plugin'], + ], + ); + await registry.close(); +}); + +test('a broken plugin is listed as failed while its working neighbor registers', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, 'a-working', v2RegistrationSource('working')); + selectV2Plugin(home, 'b-throws-on-import', 'throw new Error("import failed");'); + selectV2Plugin( + home, + 'c-throws-in-factory', + 'export default () => { throw new Error("factory failed"); };', + ); + selectV2Plugin( + home, + 'd-invalid', + `export default () => ({ apiVersion: 2, provider: 'Not Valid', credentials: { variables: [] }, setup() {} });`, + ); + selectV2Plugin( + home, + 'e-setup-fails', + `export default () => ({ apiVersion: 2, provider: 'setup-fails', credentials: { variables: [] }, setup: async () => { throw new Error('setup failed'); } });`, + ); + selectPlugin(home, 'f-bad-owner', 'bad-owner', registrationSource('other-id')); + const registry = await startStore(env); + + assert.deepEqual( + registry.registrations.map(({ provider }) => provider), + ['working'], + ); + assert.deepEqual( + registry.inventory.map(({ package: name, state, error }) => [name, state, error?.reason]), + [ + ['a-working', 'active', undefined], + ['b-throws-on-import', 'failed', 'plugin_load_failed'], + ['c-throws-in-factory', 'failed', 'plugin_load_failed'], + ['d-invalid', 'failed', 'invalid_plugin'], + ['e-setup-fails', 'failed', 'plugin_setup_failed'], + ['f-bad-owner', 'failed', 'invalid_plugin'], + ], + ); + assert.match(registry.inventory[1]!.error!.message, /import failed/); + assert.match(registry.inventory[4]!.error!.message, /setup failed/); + await registry.close(); +}); + +test('the first plugin to name a provider claims it even when it fails, and later ones are failed', async () => { + const { home, env } = pluginHome(); + selectV2Plugin( + home, + 'a-claims-then-fails', + `export default () => ({ apiVersion: 2, provider: 'shared', credentials: { variables: [] }, setup: async () => { throw new Error('setup failed'); } });`, + ); + selectV2Plugin(home, 'b-duplicate', v2RegistrationSource('shared')); + selectV2Plugin(home, 'c-builtin-id', v2RegistrationSource('limrun')); + selectV2Plugin(home, 'd-routing-id', v2RegistrationSource('proxy')); + const registry = await startStore(env); + + assert.deepEqual(registry.registrations, []); + assert.deepEqual( + registry.inventory.map(({ package: name, state, error }) => [name, state, error?.reason]), + [ + ['a-claims-then-fails', 'failed', 'plugin_setup_failed'], + ['b-duplicate', 'failed', 'duplicate_provider'], + ['c-builtin-id', 'failed', 'duplicate_provider'], + ['d-routing-id', 'failed', 'reserved_provider'], + ], + ); + assert.deepEqual(registry.claimedProviders, ['limrun', 'shared']); + await registry.close(); }); -test('duplicate providers and incompatible ABI refuse before executing any plugin', async () => { +test('a v1 manifest is dispatched before any factory runs and an incompatible one is only listed', async () => { const { home, env } = pluginHome(); const marker = path.join(home, 'evaluated'); selectPlugin( home, 'one', - 'limrun', + 'one', `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`, ); - await assert.rejects(loadProviderPlugins(env, policy(['limrun'])), { code: 'INVALID_ARGS' }); - assert.ok(!fs.existsSync(marker)); selectPlugin(home, 'two', 'other', 'throw new Error("evaluated");', 2); - await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' }); + const { resolveProviderPlugins, claimProviderIds } = await import('./registry.ts'); + const { storePluginCandidates } = await import('./store.ts'); + const resolved = await resolveProviderPlugins([ + ...claimProviderIds(['limrun']), + ...(await storePluginCandidates(env)), + ]); + assert.ok(!fs.existsSync(marker)); + assert.deepEqual( + resolved.inventory().map(({ package: name, state, error }) => [name, state, error?.reason]), + [ + ['one', 'active', undefined], + ['two', 'failed', 'incompatible_plugin'], + ], + ); +}); + +test('entry discovery prefers ./agent-device and falls back to the root export only when it is absent', async () => { + const { home, env } = pluginHome(); + const root = v2RegistrationSource('from-root'); + selectV2Plugin(home, 'a-root-fallback', root, { + exports: { '.': { import: './plugin.mjs' }, './other': './other.mjs' }, + }); + selectV2Plugin(home, 'b-main-fallback', v2RegistrationSource('from-main'), { + exports: null, + manifest: { main: './plugin.mjs' }, + }); + selectV2Plugin(home, 'c-subpath-wins', v2RegistrationSource('from-subpath'), { + exports: { '.': './root.mjs', './agent-device': './plugin.mjs' }, + files: { 'root.mjs': 'throw new Error("root must not load");' }, + }); + selectV2Plugin(home, 'd-subpath-broken', 'throw new Error("subpath import failed");', { + exports: { '.': './root.mjs', './agent-device': './plugin.mjs' }, + files: { 'root.mjs': v2RegistrationSource('from-root-after-failure') }, + }); + selectV2Plugin(home, 'e-subpath-condition-missing', root, { + exports: { '.': './plugin.mjs', './agent-device': { require: './plugin.mjs' } }, + }); + const registry = await startStore(env); + + assert.deepEqual( + registry.inventory.map(({ package: name, provider, state }) => [name, provider, state]), + [ + ['a-root-fallback', 'from-root', 'active'], + ['b-main-fallback', 'from-main', 'active'], + ['c-subpath-wins', 'from-subpath', 'active'], + ['d-subpath-broken', undefined, 'failed'], + ['e-subpath-condition-missing', undefined, 'failed'], + ], + ); + await registry.close(); }); -test('startup failure cleans up constructed runtimes, including malformed owners', async () => { +test('startup failure of one plugin cleans up its constructed runtime and keeps the others', async () => { for (const instance of ["'test'", 'undefined', '42', '" "']) { const { home, env } = pluginHome(); const first = path.join(home, 'first-shutdown'); @@ -54,26 +241,73 @@ test('startup failure cleans up constructed runtimes, including malformed owners `instance: ${instance}`, ); selectPlugin(home, 'two', 'two', source); - await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' }); - assert.ok(fs.existsSync(first)); + const registry = await startStore(env); + assert.deepEqual( + registry.registrations.map(({ provider }) => provider), + ['one'], + ); + assert.equal(registry.inventory[1]!.state, 'failed'); assert.ok(fs.existsSync(invalid)); + assert.ok(!fs.existsSync(first)); + await registry.close(); + assert.ok(fs.existsSync(first)); } }); -test('cleanup throwing synchronously preserves the factory failure', async () => { +test('a plugin dispose runs after its runtime shuts down', async () => { const { home, env } = pluginHome(); - const marker = path.join(home, 'shutdown'); - const source = registrationSource('one').replace( - 'shutdown: async () => { }', - 'shutdown: () => { fs.writeFileSync(host.env.AGENT_DEVICE_HOME + "/shutdown", "shutdown"); throw new Error("cleanup"); }', + const log = path.join(home, 'order'); + selectV2Plugin( + home, + 'disposing', + `import fs from 'node:fs'; + export default (options) => ({ + apiVersion: 2, provider: 'disposing', credentials: { variables: [] }, + setup: async () => ({ + runtime: { provider: 'disposing', ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: {}, + shutdown: async () => { fs.appendFileSync(options.log, 'runtime;'); } }, + platformModule: { owner: { kind: 'provider-runtime', provider: 'disposing', instance: 'test' }, loadRuntime: async () => { throw new Error('lazy'); } }, + dispose: async () => { fs.appendFileSync(options.log, 'dispose;'); }, + }), + });`, + { selectionOptions: { log } }, ); - selectPlugin(home, 'one', 'one', source); - selectPlugin(home, 'two', 'two', 'export default () => { throw new Error("factory failed"); };'); - await assert.rejects(loadProviderPlugins(env, policy()), /factory failed/); - assert.ok(fs.existsSync(marker)); + const registry = await startStore(env); + await registry.close(); + await registry.close(); + assert.equal(fs.readFileSync(log, 'utf8'), 'runtime;dispose;'); }); -test('connection callbacks run from the installed plugin and always release runtimes', async () => { +test('a frozen runtime registers untouched, serves its facets, and is shut down and disposed once', async () => { + const { home, env } = pluginHome(); + const log = path.join(home, 'frozen'); + selectV2Plugin( + home, + 'frozen', + `import fs from 'node:fs'; + export default (options) => ({ + apiVersion: 2, provider: 'frozen', credentials: { variables: [] }, + setup: async () => ({ + runtime: Object.freeze({ provider: 'frozen', ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: Object.freeze({}), + shutdown: async () => { fs.appendFileSync(options.log, 'runtime;'); } }), + platformModule: Object.freeze({ owner: Object.freeze({ kind: 'provider-runtime', provider: 'frozen', instance: 'test' }), loadRuntime: async () => { throw new Error('lazy'); } }), + dispose: async () => { fs.appendFileSync(options.log, 'dispose;'); }, + }), + });`, + { selectionOptions: { log } }, + ); + const registry = await startStore(env); + const { runtime } = registry.registrations[0]!; + assert.equal(Object.isFrozen(runtime), true); + assert.equal(runtime.ownsDevice({} as never), false); + await registry.close(); + await registry.close(); + assert.equal(fs.readFileSync(log, 'utf8'), 'runtime;dispose;'); +}); + +test('connection callbacks run from a v1 plugin and always release its runtime', async () => { const { home, env } = pluginHome(); const marker = path.join(home, 'shutdown'); const source = registrationSource('example', marker).replace( @@ -82,17 +316,23 @@ test('connection callbacks run from the installed plugin and always release runt ); selectPlugin(home, 'example', 'example', source); selectPlugin(home, 'unrelated', 'unrelated', 'throw new Error("unrelated plugin evaluated");'); + const manifestPath = path.join( + home, + 'plugins', + JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')).plugins.example + .installation, + 'node_modules/example/package.json', + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.agentDevicePlugin.connection = LOCAL_CONNECTION; + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const flags = { json: false, help: false, version: false }; + const profile = await withPluginConnection( 'example', + ['limrun'], env, - policy(), - async (connection) => - await connection.resolve({ - flags: { json: false, help: false, version: false }, - stateDir: home, - cwd: home, - env, - }), + async (connection) => await connection.resolve({ flags, stateDir: home, cwd: home, env }), ); assert.equal(profile.profile.platform, 'android'); assert.ok(fs.existsSync(marker)); @@ -100,66 +340,288 @@ test('connection callbacks run from the installed plugin and always release runt await assert.rejects( withPluginConnection( 'example', + ['limrun'], env, - policy(), - async (connection) => - await connection.verify({ flags: { json: false, help: false, version: false }, env }), + async (connection) => await connection.verify({ flags, env }), ), { code: 'COMMAND_FAILED' }, ); assert.ok(fs.existsSync(marker)); }); -test('the supplied policy adapts a foreign factory result and its failure releases earlier runtimes', async () => { +test('a v2 plugin connection runs without setting up any runtime', async () => { const { home, env } = pluginHome(); - const first = path.join(home, 'first-shutdown'); - selectPlugin(home, 'one', 'one', registrationSource('one', first)); - selectPlugin(home, 'adapted', 'adapted', 'export default () => ({ foreign: true });'); - const contexts: unknown[] = []; - const adapted = (provider: string): ProviderPluginRegistration => - ({ - runtime: { - provider, - ownsDevice: () => false, - getInteractor: () => undefined, - deviceInventoryProvider: async () => [], - leaseLifecycle: {}, - shutdown: async () => {}, - }, - platformModule: { - owner: { kind: 'provider-runtime', provider, instance: 'test' }, - loadRuntime: async () => { - throw new Error('lazy'); - }, - }, - }) as unknown as ProviderPluginRegistration; - const adaptForeign = (provider: string): PluginLoadPolicy => ({ - reservedProviders: [], - adaptFactoryResult: (result, { plugin, host }) => { - if (typeof result !== 'object' || result === null || !('foreign' in result)) return undefined; - contexts.push({ plugin, options: host.options }); - return adapted(provider); - }, - }); - const registrations = await loadProviderPlugins(env, adaptForeign('adapted')); + const marker = path.join(home, 'setup-ran'); + selectV2Plugin( + home, + 'example', + v2RegistrationSource('example', { + extra: `connection: { + capabilities: ${JSON.stringify(LOCAL_CONNECTION)}, + resolve: ({ host }) => ({ profile: { leaseProvider: 'example', platform: 'ios', device: host.env.AGENT_DEVICE_HOME + ':' + (typeof host.createError) } }), + verify: async () => { throw new Error('verify failed'); }, + },`, + }).replace( + 'setup: async host => (', + "setup: async host => (fs.writeFileSync(options.marker, 'x'), ", + ), + { selectionOptions: { marker } }, + ); + const flags = { json: false, help: false, version: false }; + + const resolved = await withPluginConnection( + 'example', + ['limrun'], + env, + async (connection) => await connection.resolve({ flags, stateDir: home, cwd: home, env }), + ); + assert.equal(resolved.profile.platform, 'ios'); + assert.equal(resolved.profile.device, `${home}:function`); + assert.ok(!fs.existsSync(marker)); + await assert.rejects( + withPluginConnection('missing', ['limrun'], env, async () => undefined), + { code: 'INVALID_ARGS' }, + ); +}); + +const LOCAL_CONNECTION = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: true, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: false, +}; + +test('a WebDriver descriptor is adapted by the policy, and a mismatched provider never reaches it', async () => { + const { home, env } = pluginHome(); + const source = webDriverPluginSource('example'); + selectPlugin(home, 'example', 'example', source); + const registry = await startStore(env, []); assert.deepEqual( - registrations.map(({ runtime }) => runtime.provider), - ['one', 'adapted'], + registry.registrations.map(({ provider, runtime }) => [provider, runtime.provider]), + [['example', 'example']], ); - assert.deepEqual(contexts, [ - { plugin: { name: 'adapted', provider: 'adapted' }, options: { region: 'eu' } }, - ]); - assert.ok(!fs.existsSync(first)); - await assert.rejects(loadProviderPlugins(env, adaptForeign('other')), { - code: 'INVALID_ARGS', - }); - assert.ok(fs.existsSync(first)); + assert.deepEqual(adaptedDescriptors, ['example']); + await registry.close(); + + adaptedDescriptors.length = 0; + const other = pluginHome(); + selectPlugin(other.home, 'example', 'wrong', source); + const mismatched = await startStore(other.env, []); + assert.deepEqual(mismatched.registrations, []); + assert.equal(mismatched.inventory[0]!.error?.reason, 'invalid_plugin'); + assert.deepEqual(adaptedDescriptors, []); }); -test('a factory returning a primitive is refused as an invalid plugin', async () => { +test('a factory returning a primitive is listed as an invalid plugin', async () => { for (const value of ['42', '"runtime"', 'true']) { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', `export default () => ${value};`); - await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' }); + const registry = await startStore(env, []); + assert.deepEqual(registry.registrations, []); + assert.equal(registry.inventory[0]!.state, 'failed'); + assert.equal(registry.inventory[0]!.error?.reason, 'invalid_plugin'); } }); + +test('a damaged config is isolated as one failed store entry', async () => { + const { home, env } = pluginHome(); + fs.writeFileSync(path.join(home, 'config.json'), '{ not json'); + const registry = await startStore(env); + assert.deepEqual(registry.registrations, []); + assert.equal(registry.inventory.length, 1); + assert.equal(registry.inventory[0]!.source, 'store'); + assert.equal(registry.inventory[0]!.state, 'failed'); +}); + +test('a package with a plain main and no exports loads, and main is ignored once exports exist', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, 'a-plain-main', v2RegistrationSource('plain-main'), { + exports: null, + manifest: { main: 'plugin.mjs' }, + }); + selectV2Plugin(home, 'b-exports-without-root', v2RegistrationSource('ignored-main'), { + exports: { './other': './plugin.mjs' }, + manifest: { main: './plugin.mjs' }, + }); + const registry = await startStore(env); + assert.deepEqual( + registry.inventory.map(({ package: name, provider, state }) => [name, provider, state]), + [ + ['a-plain-main', 'plain-main', 'active'], + ['b-exports-without-root', undefined, 'failed'], + ], + ); + await registry.close(); +}); + +test('a selection whose installation is not a string is isolated as one failed entry', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, 'b-working', v2RegistrationSource('working')); + const config = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')); + config.plugins['a-damaged'] = { installation: 5 }; + fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify(config)); + const registry = await startStore(env); + assert.deepEqual( + registry.inventory.map(({ package: name, state }) => [name, state]), + [ + ['a-damaged', 'failed'], + ['b-working', 'active'], + ], + ); + await registry.close(); +}); + +test('a plugin whose byBackend lists an undefined value is listed as invalid', async () => { + const { home, env } = pluginHome(); + selectV2Plugin( + home, + 'a-undefined-backend', + v2RegistrationSource('undefined-backend', { + credentials: '{ variables: [], byBackend: { ios: undefined } }', + }), + ); + const registry = await startStore(env); + assert.equal(registry.inventory[0]!.state, 'failed'); + assert.equal(registry.inventory[0]!.error?.reason, 'invalid_plugin'); + await registry.close(); +}); + +test('a rejected setup shuts its runtime down before it disposes', async () => { + const { home, env } = pluginHome(); + const log = path.join(home, 'order'); + selectV2Plugin( + home, + 'rejected', + `import fs from 'node:fs'; + export default (options) => ({ + apiVersion: 2, provider: 'rejected', credentials: { variables: [] }, + setup: async () => ({ + runtime: { provider: 'other-id', shutdown: async () => { fs.appendFileSync(options.log, 'runtime;'); } }, + platformModule: {}, + dispose: async () => { fs.appendFileSync(options.log, 'dispose;'); }, + }), + });`, + { selectionOptions: { log } }, + ); + const registry = await startStore(env); + assert.equal(registry.inventory[0]!.state, 'failed'); + assert.equal(fs.readFileSync(log, 'utf8'), 'runtime;dispose;'); +}); + +test('a typed error without a reason gets the stage reason, and one with a reason keeps it', async () => { + const { resolveProviderPlugins } = await import('./registry.ts'); + const resolved = await resolveProviderPlugins([ + { + source: 'embedder', + package: 'no-reason', + load: () => { + throw new AppError('INVALID_ARGS', 'bad options', { hint: 'fix them' }); + }, + }, + { + source: 'embedder', + package: 'with-reason', + load: () => { + throw new AppError('INVALID_ARGS', 'bad plugin', { reason: 'invalid_plugin' }); + }, + }, + ]); + assert.equal(resolved.inventory()[0]!.error?.hint, 'fix them'); + assert.deepEqual( + resolved.inventory().map(({ error }) => [error?.code, error?.reason]), + [ + ['INVALID_ARGS', 'plugin_load_failed'], + ['INVALID_ARGS', 'invalid_plugin'], + ], + ); +}); + +test('connect reuses the primed store instead of running every factory again', async () => { + const { home, env } = pluginHome(); + const log = path.join(home, 'factory-calls'); + selectV2Plugin( + home, + 'counted', + v2RegistrationSource('counted', { + extra: `connection: { + capabilities: ${JSON.stringify(LOCAL_CONNECTION)}, + resolve: () => ({ profile: { leaseProvider: 'counted', platform: 'ios' } }), + verify: async () => { throw new Error('unused'); }, + },`, + }).replace( + 'export default (options = {}) => ({', + "export default (options = {}) => (fs.appendFileSync(options.log, 'x'), {", + ), + { selectionOptions: { log } }, + ); + const { primePluginConnections } = await import('./connection.ts'); + const flags = { json: false, help: false, version: false }; + const connect = async (provider: string) => + await withPluginConnection( + provider, + ['limrun'], + env, + async (connection) => await connection.resolve({ flags, stateDir: home, cwd: home, env }), + ); + + await primePluginConnections(env, ['limrun']); + await connect('counted'); + await connect('counted'); + assert.equal(fs.readFileSync(log, 'utf8'), 'x'); + await assert.rejects(connect('limrun'), { code: 'INVALID_ARGS' }); + assert.equal(fs.readFileSync(log, 'utf8'), 'x'.repeat(2)); +}); + +test('one plugin failing to shut down does not stop the others from shutting down and disposing', async () => { + const { home, env } = pluginHome(); + const log = path.join(home, 'close-log'); + const source = (name: string, shutdownBody: string) => `import fs from 'node:fs'; + export default (options) => ({ + apiVersion: 2, provider: ${JSON.stringify(name)}, credentials: { variables: [] }, + setup: async () => ({ + runtime: { provider: ${JSON.stringify(name)}, ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: {}, + shutdown: async () => { ${shutdownBody} } }, + platformModule: { owner: { kind: 'provider-runtime', provider: ${JSON.stringify(name)}, instance: 'test' }, loadRuntime: async () => { throw new Error('lazy'); } }, + dispose: async () => { fs.appendFileSync(options.log, ${JSON.stringify(`${name}-dispose;`)}); }, + }), + });`; + selectV2Plugin( + home, + 'a-throws', + source( + 'throws', + "fs.appendFileSync(options.log, 'throws-shutdown;'); throw new Error('boom');", + ), + { selectionOptions: { log } }, + ); + selectV2Plugin( + home, + 'b-fine', + source('fine', "fs.appendFileSync(options.log, 'fine-shutdown;');"), + { selectionOptions: { log } }, + ); + const registry = await startStore(env); + assert.equal(registry.registrations.length, 2); + + const outcomes = await registry.close(); + + const events = fs.readFileSync(log, 'utf8').split(';').filter(Boolean).sort(); + assert.deepEqual(events, ['fine-dispose', 'fine-shutdown', 'throws-dispose', 'throws-shutdown']); + assert.deepEqual( + outcomes.map(({ provider, source, shutdown, dispose }) => [ + provider, + source, + shutdown.status === 'failed' ? shutdown.error.message : shutdown.status, + dispose.status, + ]), + [ + ['throws', 'store', 'boom', 'ok'], + ['fine', 'store', 'ok', 'ok'], + ], + ); + assert.equal(await registry.close(), outcomes); +}); diff --git a/packages/plugins/src/load.ts b/packages/plugins/src/load.ts index 1ff9ec5053..66d9c0d4b7 100644 --- a/packages/plugins/src/load.ts +++ b/packages/plugins/src/load.ts @@ -1,137 +1,32 @@ -import { pathToFileURL } from 'node:url'; import { AppError } from '@agent-device/kernel/errors'; -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/platform-runtime-operations'; -import { installedPlugins } from './store.ts'; -import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; -import { createPluginHost, type ProviderPluginHost } from './host.ts'; -import type { PluginConnection } from './connection.ts'; - -export type ProviderPluginRegistration = Readonly<{ - runtime: ProviderDeviceRuntime; - platformModule: PlatformRuntimeProviderModule; - connection?: PluginConnection; -}>; - -export type PluginFactoryContext = Readonly<{ - plugin: Readonly<{ name: string; provider: string }>; - host: ProviderPluginHost; -}>; - -/** - * What the composing root decides for every load: provider ids a plugin may not claim, and how to - * turn a factory result that is not itself a registration into one. `adaptFactoryResult` returns - * undefined for a result it does not own, which the loader then validates as a registration. - */ -export type PluginLoadPolicy = Readonly<{ - reservedProviders: readonly string[]; - adaptFactoryResult( - result: unknown, - context: PluginFactoryContext, - ): ProviderPluginRegistration | undefined | Promise; -}>; - -export async function loadProviderPlugins( - env: NodeJS.ProcessEnv, - policy: PluginLoadPolicy, - onlyProvider?: string, -): Promise { - const plugins = installedPlugins(env); - assertUniquePluginProviders(plugins, policy.reservedProviders); - const registrations: ProviderPluginRegistration[] = []; - try { - for (const plugin of plugins.filter( - (plugin) => onlyProvider === undefined || plugin.agentDevicePlugin.provider === onlyProvider, - )) { - const registration = await instantiateProviderPlugin(plugin, env, policy); - registrations.push(registration); - validateProviderPlugin(registration, plugin); - } - return registrations; - } catch (error) { - await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); - throw error; - } -} +import type { PluginConnectionCallbacks } from './connection.ts'; +import { primedPluginConnection } from './connection.ts'; +/** Runs a store plugin's `connect` callbacks without setting up any runtime. */ export async function withPluginConnection( provider: string, + builtinProviders: readonly string[], env: NodeJS.ProcessEnv, - policy: PluginLoadPolicy, - runConnection: (connection: PluginConnection) => Promise, + runConnection: (connection: PluginConnectionCallbacks) => Promise, ): Promise { - const registrations = await loadProviderPlugins(env, policy, provider); - try { - const connection = registrations.find( - (entry) => entry.runtime.provider === provider, - )?.connection; - if (!connection) - throw new AppError('INVALID_ARGS', `Plugin does not register connect: ${provider}`); - return await runConnection(connection); - } finally { - await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); - } -} - -async function instantiateProviderPlugin( - plugin: ReturnType[number], - env: NodeJS.ProcessEnv, - policy: PluginLoadPolicy, -): Promise { - const module = await import( - pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href - ); - if (typeof module.default !== 'function') - throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${plugin.name}`); - const host = createPluginHost(env, plugin.selection.options); - const result: unknown = await (module.default as (host: ProviderPluginHost) => unknown)(host); - const registration = - (await policy.adaptFactoryResult(result, { - plugin: { name: plugin.name, provider: plugin.agentDevicePlugin.provider }, - host, - })) ?? (result as ProviderPluginRegistration); - if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { - throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); - } - return registration; -} - -function validateProviderPlugin( - registration: ProviderPluginRegistration, - plugin: ReturnType[number], -): void { - if ( - registration.runtime.provider !== plugin.agentDevicePlugin.provider || - !hasProviderFacets(registration.runtime) || - !matchesRuntimeOwner(registration) || - (plugin.agentDevicePlugin.connection && - (typeof registration.connection?.resolve !== 'function' || - typeof registration.connection?.verify !== 'function')) - ) { + const primed = primedPluginConnection(provider, env); + if (primed) return await runConnection(primed); + const [{ claimProviderIds, resolveProviderPlugins }, { storePluginCandidates }] = + await Promise.all([import('./registry.ts'), import('./store.ts')]); + const resolved = await resolveProviderPlugins([ + ...claimProviderIds(builtinProviders), + ...(await storePluginCandidates(env)), + ]); + const connection = resolved.connections.get(provider); + if (!connection) { + const failed = resolved.inventory().find((entry) => entry.provider === provider); throw new AppError( 'INVALID_ARGS', - `Plugin runtime owner does not match its declaration: ${plugin.name}`, + failed?.error + ? `Plugin ${provider} failed to load: ${failed.error.message}` + : `Plugin does not register connect: ${provider}`, + failed?.error?.reason ? { reason: failed.error.reason, provider } : { provider }, ); } -} - -function matchesRuntimeOwner(registration: ProviderPluginRegistration): boolean { - const owner = registration.platformModule?.owner; - return ( - owner?.kind === 'provider-runtime' && - owner.provider === registration.runtime.provider && - typeof owner.instance === 'string' && - owner.instance.trim().length > 0 && - typeof registration.platformModule.loadRuntime === 'function' - ); -} - -function hasProviderFacets(runtime: ProviderDeviceRuntime): boolean { - return ( - runtime.leaseLifecycle !== null && - typeof runtime.leaseLifecycle === 'object' && - (['ownsDevice', 'getInteractor', 'deviceInventoryProvider'] as const).every( - (method) => typeof runtime[method] === 'function', - ) - ); + return await runConnection(connection); } diff --git a/packages/plugins/src/manifest.ts b/packages/plugins/src/manifest.ts index 70e68be140..440725b231 100644 --- a/packages/plugins/src/manifest.ts +++ b/packages/plugins/src/manifest.ts @@ -3,8 +3,13 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; -const PROVIDER_PLUGIN_API_VERSION = 1; -type PluginManifest = { +/** Provider ids that name daemon routing, not a runtime a plugin can supply. */ +export const ROUTING_PROVIDER_IDENTITIES: readonly string[] = ['cloud', 'proxy']; + +const V1_PLUGIN_API_VERSION = 1; + +/** A v1 plugin package: the manifest declares what the host reads before any plugin code runs. */ +export type PluginManifest = { name: string; version: string; agentDevicePlugin: { @@ -16,16 +21,41 @@ type PluginManifest = { }; }; -export function assertUniquePluginProviders( - plugins: readonly PluginManifest[], - reserved: readonly string[], +const ENVIRONMENT_VARIABLE = /^[A-Z_][A-Z0-9_]*$/; + +function invalidManifest(message: string): AppError { + return new AppError('INVALID_ARGS', message, { reason: 'invalid_plugin' }); +} + +export function assertLocalConnectionCapabilities( + policy: ConnectionProviderCapabilities | undefined, ): void { - const providers = new Set(reserved); - for (const plugin of plugins) { - const provider = plugin.agentDevicePlugin.provider; - if (providers.has(provider)) - throw new AppError('INVALID_ARGS', `Duplicate provider plugin: ${provider}`); - providers.add(provider); + if (policy === undefined) return; + if ( + !policy || + typeof policy !== 'object' || + policy.leaseKind !== 'direct-device-provider' || + [ + 'requiresAppAttachment', + 'requiresRemoteDaemon', + 'supportsArtifacts', + 'supportsDeferredAppSelection', + 'supportsDirectPortReverse', + 'usesCloudWebDriverLease', + ].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') || + policy.requiresRemoteDaemon + ) { + throw invalidManifest('Plugin connection must declare local provider capabilities'); + } +} + +export function assertCredentialVariables(variables: unknown): void { + if ( + variables !== undefined && + (!Array.isArray(variables) || + !variables.every((name) => typeof name === 'string' && ENVIRONMENT_VARIABLE.test(name))) + ) { + throw invalidManifest('Plugin credential variables must list environment variable names'); } } @@ -47,7 +77,7 @@ export function readPluginManifest(directory: string): PluginManifest { typeof manifest?.name !== 'string' || typeof manifest?.version !== 'string' || !declaration || - declaration.apiVersion !== PROVIDER_PLUGIN_API_VERSION || + declaration.apiVersion !== V1_PLUGIN_API_VERSION || typeof declaration.provider !== 'string' || !/^[a-z][a-z0-9-]*$/.test(declaration.provider) || typeof declaration.entry !== 'string' || @@ -55,61 +85,31 @@ export function readPluginManifest(directory: string): PluginManifest { ) { throw new AppError('INVALID_ARGS', 'Package does not declare a compatible provider plugin', { reason: 'incompatible_plugin', - apiVersion: PROVIDER_PLUGIN_API_VERSION, - hint: `Install a plugin release supporting agentDevicePlugin.apiVersion ${PROVIDER_PLUGIN_API_VERSION}.`, + apiVersion: V1_PLUGIN_API_VERSION, + hint: `Install a plugin release supporting agentDevicePlugin.apiVersion ${V1_PLUGIN_API_VERSION}.`, }); } resolvePluginEntry(directory, declaration.entry); - assertLocalConnectionPolicy(declaration.connection); + assertLocalConnectionCapabilities(declaration.connection); assertCredentialVariables(declaration.credentialVariables); return manifest as PluginManifest; } -function assertLocalConnectionPolicy(policy: ConnectionProviderCapabilities | undefined): void { - if (policy === undefined) return; - if ( - !policy || - typeof policy !== 'object' || - policy.leaseKind !== 'direct-device-provider' || - [ - 'requiresAppAttachment', - 'requiresRemoteDaemon', - 'supportsArtifacts', - 'supportsDeferredAppSelection', - 'supportsDirectPortReverse', - 'usesCloudWebDriverLease', - ].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') || - policy.requiresRemoteDaemon - ) { - throw new AppError( - 'INVALID_ARGS', - 'Plugin connection must declare local provider capabilities', - ); - } -} - -function assertCredentialVariables(variables: unknown): void { - if ( - variables !== undefined && - (!Array.isArray(variables) || - !variables.every((name) => typeof name === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(name))) - ) { - throw new AppError( - 'INVALID_ARGS', - 'Plugin credentialVariables must list environment variable names', - ); - } -} - -export function resolvePluginEntry(directory: string, entry: string): string { +export function resolvePluginEntry(directory: string, target: string): string { try { const root = fs.realpathSync(directory); - const resolved = fs.realpathSync(path.resolve(directory, entry)); - if (resolved.startsWith(`${root}${path.sep}`) && fs.statSync(resolved).isFile()) + const resolved = fs.realpathSync(path.resolve(directory, target)); + if ( + target.startsWith('./') && + resolved.startsWith(`${root}${path.sep}`) && + fs.statSync(resolved).isFile() + ) { return resolved; + } } catch {} throw new AppError('INVALID_ARGS', 'Plugin entry must be a readable file inside its package', { + reason: 'plugin_load_failed', directory, - entry, + entry: target, }); } diff --git a/packages/plugins/src/registry.ts b/packages/plugins/src/registry.ts new file mode 100644 index 0000000000..e5b2e9cb90 --- /dev/null +++ b/packages/plugins/src/registry.ts @@ -0,0 +1,348 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/platform-runtime-operations'; +import type { + AgentDevicePlugin, + PluginConnection, + PluginSetupResult, + PluginWebDriverSetup, + WebDriverDescriptor, +} from './contract.ts'; +import type { RegisteredConnection } from './connection.ts'; +import { createPluginHost } from './host.ts'; +import { + NO_PLUGIN_CREDENTIALS, + type PluginCleanupOutcome, + type PluginCleanupStep, + type PluginInventoryEntry, + type PluginSource, +} from '@agent-device/contracts/plugin-inventory'; +import { describePluginFailure } from './inventory.ts'; +import { isProviderId } from './discovery.ts'; +import { ROUTING_PROVIDER_IDENTITIES } from './manifest.ts'; +import { assertProviderRuntime, invalidPlugin, validateAgentDevicePlugin } from './validate.ts'; + +/** + * One plugin a source offers the registry. Sources differ only in how a candidate loads; the + * registry validates, claims and sets up every candidate the same way. + */ +export type PluginCandidate = Readonly<{ + source: PluginSource; + package?: string; + version?: string; + /** Known before the plugin loads, such as a v1 manifest; a load failure still claims it. */ + provider?: string; + /** Claims `provider` without registering or listing: a built-in that is not configured. */ + dormant?: boolean; + /** The `options` the store passes to the host, which a v1 plugin reads. */ + hostOptions?: Record; + /** Returns the unvalidated plugin, or throws the reason it cannot load. */ + load(): Promise | unknown; +}>; + +/** Claims provider ids without loading anything, as the built-in sources do for their own. */ +export function claimProviderIds(providers: readonly string[]): PluginCandidate[] { + return providers.map((provider) => ({ + source: 'builtin', + provider, + dormant: true, + load: () => undefined, + })); +} + +/** + * What the composing root decides for every registry, so this package names no provider: how a + * plugin's `{ webDriver }` descriptor becomes a runtime. + */ +export type PluginRegistryPolicy = Readonly<{ + adaptWebDriver( + descriptor: WebDriverDescriptor, + context: Readonly<{ clientVersion: string }>, + ): Promise>; +}>; + +export type ProviderRegistration = Readonly<{ + provider: string; + source: PluginSource; + runtime: ProviderDeviceRuntime; + platformModule: PlatformRuntimeProviderModule; + /** The plugin's own cleanup, run once by {@link ProviderRegistry.close} after the runtime shuts down. */ + dispose?: () => Promise | void; +}>; + +type Slot = { + candidate: PluginCandidate; + provider?: string; + plugin?: AgentDevicePlugin; + failure?: unknown; + registration?: ProviderRegistration; +}; + +/** Candidates whose plugins loaded and validated, with duplicate providers already settled. */ +export type ResolvedProviderPlugins = Readonly<{ + /** The connection callbacks of every loaded plugin, by provider. */ + connections: ReadonlyMap; + /** Every provider id claimed, including failed and dormant plugins. */ + claimedProviders: readonly string[]; + inventory(): readonly PluginInventoryEntry[]; + /** @internal Consumed by {@link startProviderRegistry}. */ + slots: readonly Slot[]; +}>; + +export type ProviderRegistry = Readonly<{ + registrations: readonly ProviderRegistration[]; + claimedProviders: readonly string[]; + inventory: readonly PluginInventoryEntry[]; + /** + * The one cleanup owner. Shuts down every registered runtime and then runs its plugin's + * `dispose`, each plugin independently of the others, and reports what happened to each. It runs + * once; later calls return the same outcomes. It never throws and never wraps a runtime. + */ + close(): Promise; +}>; + +/** + * Loads and validates the candidates in order. The first candidate to name a provider claims it, + * even when that candidate later fails; a later one with the same id is recorded as failed. A + * plugin that cannot load never stops the others. + */ +export async function resolveProviderPlugins( + candidates: readonly PluginCandidate[], +): Promise { + const slots: Slot[] = []; + const claimed = new Set(); + for (const candidate of candidates) { + const slot: Slot = { candidate, provider: candidate.provider }; + slots.push(slot); + if (candidate.dormant) { + if (candidate.provider !== undefined) claimed.add(candidate.provider); + continue; + } + try { + const raw = await candidate.load(); + slot.provider = readProviderId(raw) ?? candidate.provider; + slot.plugin = validateAgentDevicePlugin(raw); + } catch (error) { + slot.failure = asPluginFailure(error, 'plugin_load_failed'); + } + if (slot.provider === undefined || ROUTING_PROVIDER_IDENTITIES.includes(slot.provider)) + continue; + if (claimed.has(slot.provider)) { + slot.plugin = undefined; + slot.failure ??= new AppError('INVALID_ARGS', `Duplicate provider plugin: ${slot.provider}`, { + reason: 'duplicate_provider', + provider: slot.provider, + }); + } else claimed.add(slot.provider); + } + return Object.freeze({ + connections: new Map( + slots.flatMap(({ plugin, candidate }) => + plugin?.connection + ? [ + [ + plugin.provider, + withConnectionHost(plugin.connection, candidate.hostOptions), + ] as const, + ] + : [], + ), + ), + claimedProviders: [...claimed], + inventory: () => slots.flatMap(describeSlot), + slots, + }); +} + +/** + * Sets up every loaded plugin in order. A plugin whose setup throws, or returns a runtime the + * registry refuses, is cleaned up and recorded as failed; the remaining plugins still register. + */ +export async function startProviderRegistry( + resolved: ResolvedProviderPlugins, + env: NodeJS.ProcessEnv, + policy: PluginRegistryPolicy, +): Promise { + for (const slot of resolved.slots) { + if (!slot.plugin || slot.failure !== undefined) continue; + try { + await setUpSlot(slot, slot.plugin, env, policy); + } catch (error) { + slot.failure = asPluginFailure(error, 'plugin_setup_failed'); + slot.registration = undefined; + } + } + const registrations = resolved.slots.flatMap(({ registration }) => + registration ? [registration] : [], + ); + return Object.freeze({ + registrations, + claimedProviders: resolved.claimedProviders, + inventory: resolved.inventory(), + close: once(async () => await Promise.all(registrations.map(cleanUp))), + }); +} + +function withConnectionHost( + connection: PluginConnection, + hostOptions: Record | undefined, +): RegisteredConnection { + return { + capabilities: connection.capabilities, + resolve: (context) => + connection.resolve({ ...context, host: createPluginHost(context.env, hostOptions) }), + verify: (context) => + connection.verify({ ...context, host: createPluginHost(context.env, hostOptions) }), + }; +} + +async function setUpSlot( + slot: Slot, + plugin: AgentDevicePlugin, + env: NodeJS.ProcessEnv, + policy: PluginRegistryPolicy, +): Promise { + const host = createPluginHost(env, slot.candidate.hostOptions); + const result = await plugin.setup(host); + try { + const { runtime, platformModule } = await buildRegistration( + plugin, + result, + host.clientVersion, + policy, + ); + const dispose = result.dispose?.bind(result); + slot.registration = { + provider: plugin.provider, + source: slot.candidate.source, + runtime, + platformModule, + ...(dispose ? { dispose } : {}), + }; + } catch (error) { + await cleanUpRejectedSetup(result); + throw error; + } +} + +async function buildRegistration( + plugin: AgentDevicePlugin, + result: PluginSetupResult, + clientVersion: string, + policy: PluginRegistryPolicy, +): Promise> { + if (!isRecord(result)) + throw invalidPlugin(`Plugin setup must return a runtime: ${plugin.provider}`); + if (isWebDriverSetup(result)) { + if (result.webDriver?.provider !== plugin.provider) { + throw invalidPlugin( + `WebDriver plugin provider does not match its declaration: ${plugin.provider}`, + ); + } + return await policy.adaptWebDriver(result.webDriver, { clientVersion }); + } + // The public contract names only what registration reads; the host checks the facets it calls. + const { runtime, platformModule } = result as unknown as { + runtime: ProviderDeviceRuntime; + platformModule: PlatformRuntimeProviderModule; + }; + assertProviderRuntime(plugin.provider, runtime, platformModule); + return { runtime, platformModule }; +} + +function isWebDriverSetup(result: PluginSetupResult): result is PluginWebDriverSetup { + return 'webDriver' in result; +} + +async function cleanUpRejectedSetup(result: unknown): Promise { + if (!isRecord(result)) return; + const runtime = result.runtime as ProviderDeviceRuntime | undefined; + // The same order a registered plugin shuts down in: the runtime first, then `dispose`. + try { + if (typeof runtime?.shutdown === 'function') await runtime.shutdown(); + } catch {} + try { + if (typeof result.dispose === 'function') await (result.dispose as () => unknown)(); + } catch {} +} + +/** A thrown value that is not already typed becomes a typed failure the inventory can key on. */ +function asPluginFailure(error: unknown, reason: 'plugin_load_failed' | 'plugin_setup_failed') { + if (error instanceof AppError) { + return typeof error.details?.reason === 'string' + ? error + : new AppError(error.code, error.message, { ...error.details, reason }, error); + } + return new AppError( + 'COMMAND_FAILED', + error instanceof Error ? error.message : String(error), + { reason }, + error, + ); +} + +function describeSlot(slot: Slot): PluginInventoryEntry[] { + const { candidate, plugin } = slot; + if (candidate.dormant) return []; + const base = { + ...(slot.provider !== undefined ? { provider: slot.provider } : {}), + source: candidate.source, + ...(candidate.package !== undefined ? { package: candidate.package } : {}), + ...(candidate.version !== undefined ? { version: candidate.version } : {}), + }; + if (slot.failure !== undefined || !plugin) { + return [ + { + ...base, + state: 'failed', + error: describePluginFailure(slot.failure), + credentials: NO_PLUGIN_CREDENTIALS, + }, + ]; + } + return [ + { + ...base, + state: 'active', + credentials: { + variables: [...plugin.credentials.variables], + byBackend: Object.fromEntries( + Object.entries(plugin.credentials.byBackend ?? {}).map(([backend, names]) => [ + backend, + [...names], + ]), + ), + }, + }, + ]; +} + +function readProviderId(raw: unknown): string | undefined { + return isRecord(raw) && isProviderId(raw.provider) ? raw.provider : undefined; +} + +async function cleanUp({ + provider, + source, + runtime, + dispose, +}: ProviderRegistration): Promise { + const shutdown = await attempt(async () => await runtime.shutdown()); + const disposed = await attempt(async () => await dispose?.()); + return { provider, source, shutdown, dispose: disposed }; +} + +async function attempt(step: () => Promise): Promise { + try { + await step(); + return { status: 'ok' }; + } catch (error) { + return { status: 'failed', error: describePluginFailure(error) }; + } +} + +function once(run: () => Promise): () => Promise { + let started: Promise | undefined; + return () => (started ??= run()); +} diff --git a/packages/plugins/src/store.test.ts b/packages/plugins/src/store.test.ts index ce686a8bf2..0a46f95f82 100644 --- a/packages/plugins/src/store.test.ts +++ b/packages/plugins/src/store.test.ts @@ -3,15 +3,34 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; -import { changePlugin, installedPlugins, listPlugins } from './store.ts'; -import { pluginHome, selectPlugin, writePlugin } from '@agent-device/test-kit'; +import { changePlugin, legacyStoreManifests, listPlugins } from './store.ts'; +import { + pluginHome, + selectPlugin, + selectV2Plugin, + v2RegistrationSource, + writePlugin, +} from '@agent-device/test-kit'; vi.mock('@agent-device/host-kit/command', () => ({ runCmd: vi.fn() })); afterEach(() => vi.mocked(runCmd).mockReset()); const packageName = '@example/provider'; function readPluginConfig(env: NodeJS.ProcessEnv) { - return JSON.parse(fs.readFileSync(path.join(env.AGENT_DEVICE_HOME!, 'config.json'), 'utf8')); + const file = path.join(env.AGENT_DEVICE_HOME!, 'config.json'); + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; +} + +function installedPlugins(env: NodeJS.ProcessEnv) { + const home = env.AGENT_DEVICE_HOME!; + return Object.entries(readPluginConfig(env).plugins ?? {}).map(([name, selection]) => { + const { installation, options } = selection as { installation: string; options?: unknown }; + return { + name, + selection: { installation, options }, + directory: path.join(home, 'plugins', installation, 'node_modules', name), + }; + }); } function installFixture(name = packageName, apiVersion = 1, provider = 'example') { @@ -104,16 +123,15 @@ test('invalid package sources and damaged selections fail before invoking npm', path.join(home, 'config.json'), JSON.stringify({ plugins: { one: { installation: '../other' } } }), ); - assert.throws(() => installedPlugins(env), { code: 'INVALID_ARGS' }); - assert.equal(listPlugins(env)[0]?.compatible, false); + assert.equal((await listPlugins(env, []))[0]?.compatible, false); await changePlugin('remove', 'one', env, []); - assert.deepEqual(listPlugins(env), []); + assert.deepEqual(await listPlugins(env, []), []); fs.writeFileSync( path.join(home, 'config.json'), JSON.stringify({ plugins: { UPPERCASE: null } }), ); await changePlugin('remove', 'UPPERCASE', env, []); - assert.deepEqual(listPlugins(env), []); + assert.deepEqual(await listPlugins(env, []), []); assert.equal(vi.mocked(runCmd).mock.calls.length, 0); }); @@ -150,6 +168,39 @@ test('broken siblings do not block pinned updates or unrelated additions', async assert.deepEqual(readPluginConfig(env).plugins!.broken, config.plugins!.broken); installFixture('other', 1, 'other'); await changePlugin('add', 'other', env, []); - assert.throws(() => installedPlugins(env), { code: 'INVALID_ARGS' }); + const listed = await listPlugins(env, []); + assert.deepEqual( + listed.map(({ name, compatible }) => [name, compatible]), + [ + ['@example/provider', true], + ['broken', false], + ['other', true], + ], + ); } }); + +test('a package that declares the v1 manifest and exports ./agent-device still supplies its manifest', () => { + const { home, env } = pluginHome(); + const directory = selectV2Plugin(home, 'dual', v2RegistrationSource('dual'), { + manifest: { + agentDevicePlugin: { + apiVersion: 1, + provider: 'dual', + entry: './plugin.mjs', + credentialVariables: ['DUAL_KEY'], + }, + }, + }); + selectV2Plugin(home, 'v2-only', v2RegistrationSource('v2-only')); + assert.deepEqual( + legacyStoreManifests(env).map((manifest) => manifest.agentDevicePlugin.credentialVariables), + [['DUAL_KEY']], + ); + + // An installation that holds another package supplies nothing under this selection's name. + const manifestPath = path.join(directory, 'package.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, name: 'someone-else' })); + assert.deepEqual(legacyStoreManifests(env), []); +}); diff --git a/packages/plugins/src/store.ts b/packages/plugins/src/store.ts index 9f8e6850f4..969532c20f 100644 --- a/packages/plugins/src/store.ts +++ b/packages/plugins/src/store.ts @@ -1,7 +1,8 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { pathToFileURL } from 'node:url'; +import { AppError } from '@agent-device/kernel/errors'; import { isRecord } from '@agent-device/kernel/record'; import { runCmd } from '@agent-device/host-kit/command'; import { @@ -10,7 +11,13 @@ import { resolveUserConfigPath, } from '@agent-device/host-kit/file'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; -import { readPluginManifest, assertUniquePluginProviders } from './manifest.ts'; +import type { PluginPackageEntry } from './discovery.ts'; +import { + readPluginManifest, + ROUTING_PROVIDER_IDENTITIES, + type PluginManifest, +} from './manifest.ts'; +import type { PluginCandidate } from './registry.ts'; type PluginSelection = { installation: string; @@ -46,39 +53,160 @@ function validatePluginSelection(name: string, selection: PluginSelection): void } } -export function installedPlugins(env: NodeJS.ProcessEnv) { - const home = path.dirname(resolveUserConfigPath(env)); - return Object.entries(readPluginConfig(env).plugins ?? {}).map(([name, selection]) => - readInstallation(home, name, selection), - ); +type StoreInstallation = { + name: string; + version: string; + directory: string; + selection: PluginSelection; + entry: PluginPackageEntry; +}; + +function storeHome(env: NodeJS.ProcessEnv): string { + return path.dirname(resolveUserConfigPath(env)); } -function readInstallation(home: string, name: string, selection: PluginSelection) { - validatePluginSelection(name, selection); - const directory = path.join(home, 'plugins', selection.installation, 'node_modules', name); - const manifest = readPluginManifest(directory); - if (manifest.name !== name) +function installationDirectory(home: string, name: string, installation: string): string { + return path.join(home, 'plugins', installation, 'node_modules', name); +} + +async function describeInstallation( + name: string, + directory: string, + selection: PluginSelection, +): Promise { + const { discoverPluginEntry, readPackageJson } = await import('./discovery.ts'); + const packageJson = readPackageJson(directory); + if (packageJson.name !== name) throw new AppError('INVALID_ARGS', `Plugin package identity mismatch: ${name}`); - return { ...manifest, directory, selection }; + const version = typeof packageJson.version === 'string' ? packageJson.version : undefined; + if (version === undefined) { + throw new AppError('INVALID_ARGS', `Plugin package has no version: ${name}`, { + reason: 'invalid_plugin', + }); + } + return { name, version, directory, selection, entry: discoverPluginEntry(directory) }; +} + +async function readInstallation(home: string, name: string, selection: PluginSelection) { + validatePluginSelection(name, selection); + return describeInstallation( + name, + installationDirectory(home, name, selection.installation), + selection, + ); +} + +async function loadInstallation({ name, directory, selection, entry }: StoreInstallation) { + if (entry.kind === 'v1') { + const { adaptLegacyPlugin } = await import('./legacy.ts'); + return adaptLegacyPlugin(entry.manifest, directory, selection.options); + } + const module = await import(pathToFileURL(entry.file).href); + if (typeof module.default !== 'function') + throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${name}`, { + reason: 'invalid_plugin', + }); + return await module.default(selection.options); +} + +/** + * The store's contribution to the provider registry, in package-name order. Nothing here runs + * plugin code: a selection or package that cannot be read becomes a candidate that fails when + * loaded, so one damaged installation never hides the others. + */ +export async function storePluginCandidates(env: NodeJS.ProcessEnv): Promise { + const home = storeHome(env); + let selections: [string, PluginSelection][]; + try { + selections = Object.entries(readPluginConfig(env).plugins ?? {}); + } catch (error) { + return [{ source: 'store', load: () => Promise.reject(error) }]; + } + const ordered = selections.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + return await Promise.all( + ordered.map(async ([name, selection]): Promise => { + try { + const installation = await readInstallation(home, name, selection); + return { + source: 'store', + package: name, + version: installation.version, + ...(installation.entry.kind === 'v1' + ? { provider: installation.entry.manifest.agentDevicePlugin.provider } + : {}), + ...(selection.options ? { hostOptions: selection.options } : {}), + load: () => loadInstallation(installation), + }; + } catch (error) { + return { + source: 'store', + package: name, + ...(await readSelectionVersion(home, name, selection)), + load: () => Promise.reject(error), + }; + } + }), + ); +} + +// Only a selection that passed validation names a directory this process may read. +async function readSelectionVersion( + home: string, + name: string, + selection: PluginSelection, +): Promise<{ version?: string }> { + try { + validatePluginSelection(name, selection); + const { readPackageJson } = await import('./discovery.ts'); + const version = readPackageJson( + installationDirectory(home, name, selection.installation), + ).version; + return typeof version === 'string' ? { version } : {}; + } catch { + return {}; + } } -export function listPlugins(env: NodeJS.ProcessEnv) { - const home = path.dirname(resolveUserConfigPath(env)); - return Object.entries(readPluginConfig(env).plugins ?? {}).map(([name, selection]) => { +/** + * The v1 manifests in the store, read without executing anything, including those of a package + * that also exports `./agent-device`; unreadable selections are skipped. + */ +export function legacyStoreManifests(env: NodeJS.ProcessEnv): PluginManifest[] { + const home = storeHome(env); + let selections: [string, PluginSelection][]; + try { + selections = Object.entries(readPluginConfig(env).plugins ?? {}); + } catch { + return []; + } + return selections.flatMap(([name, selection]) => { try { - const manifest = readInstallation(home, name, selection); - return { - name, - version: manifest.version, - provider: manifest.agentDevicePlugin.provider, - compatible: true, - }; - } catch (error) { - return { name, compatible: false, error: normalizeError(error) }; + validatePluginSelection(name, selection); + const manifest = readPluginManifest( + installationDirectory(home, name, selection.installation), + ); + return manifest.name === name ? [manifest] : []; + } catch { + return []; } }); } +export async function listPlugins(env: NodeJS.ProcessEnv, builtinProviders: readonly string[]) { + const { claimProviderIds, resolveProviderPlugins } = await import('./registry.ts'); + const resolved = await resolveProviderPlugins([ + ...claimProviderIds(builtinProviders), + ...(await storePluginCandidates(env)), + ]); + return resolved.inventory().map((entry) => ({ + name: entry.package, + ...(entry.version !== undefined ? { version: entry.version } : {}), + ...(entry.provider !== undefined ? { provider: entry.provider } : {}), + compatible: entry.state === 'active', + ...(entry.error ? { error: entry.error } : {}), + })); +} + type PluginAction = 'add' | 'update' | 'remove'; function parsePluginRequest(action: PluginAction, input: string) { @@ -125,10 +253,14 @@ async function stagePlugin( ], { cwd: project, env, timeoutMs: 120_000 }, ); - const manifest = readPluginManifest(path.join(project, 'node_modules', name)); - if (manifest.name !== name) - throw new AppError('INVALID_ARGS', `Plugin package identity mismatch: ${name}`); - return { installation, project, manifest }; + const staged = await describeInstallation( + name, + installationDirectory(home, name, installation), + { + installation, + }, + ); + return { installation, project, entry: staged.entry }; } catch (error) { fs.rmSync(project, { recursive: true, force: true }); throw error; @@ -138,7 +270,7 @@ async function stagePlugin( export async function changePlugin( action: PluginAction, input: string, - env: NodeJS.ProcessEnv, + env: NodeJS.ProcessEnv = process.env, reservedProviders: readonly string[], ): Promise { const { name, version } = parsePluginRequest(action, input); @@ -162,16 +294,13 @@ export async function changePlugin( const requested = action === 'update' && isVersionSelector(previous?.version) ? previous.version : version; staged = await stagePlugin(home, name, requested, env); - const siblings = Object.entries(selections) - .filter(([other]) => other !== name) - .flatMap(([other, selection]) => { - try { - return [readInstallation(home, other, selection)]; - } catch { - return []; - } - }); - assertUniquePluginProviders([...siblings, staged.manifest], reservedProviders); + assertUniqueLegacyProviders( + [ + ...legacyStoreManifests(env).filter((manifest) => manifest.name !== name), + ...(staged.entry.kind === 'v1' ? [staged.entry.manifest] : []), + ], + reservedProviders, + ); selections[name] = { installation: staged.installation, version: requested, @@ -191,6 +320,26 @@ export async function changePlugin( } } +// A v1 manifest names its provider before any code runs, so a clash is refused at install time. A +// v2 package names its provider only once loaded; the registry records a clash then. +function assertUniqueLegacyProviders( + manifests: readonly PluginManifest[], + reserved: readonly string[], +): void { + const providers = new Set([...ROUTING_PROVIDER_IDENTITIES, ...reserved]); + for (const { agentDevicePlugin } of manifests) { + if (providers.has(agentDevicePlugin.provider)) + throw new AppError( + 'INVALID_ARGS', + `Duplicate provider plugin: ${agentDevicePlugin.provider}`, + { + reason: 'duplicate_provider', + }, + ); + providers.add(agentDevicePlugin.provider); + } +} + function isPackageName(value: string): boolean { return /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value); } diff --git a/packages/plugins/src/validate.ts b/packages/plugins/src/validate.ts new file mode 100644 index 0000000000..9c573d495a --- /dev/null +++ b/packages/plugins/src/validate.ts @@ -0,0 +1,101 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import { isProviderId } from './discovery.ts'; +import { + assertCredentialVariables, + assertLocalConnectionCapabilities, + ROUTING_PROVIDER_IDENTITIES, +} from './manifest.ts'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/platform-runtime-operations'; +import { PROVIDER_PLUGIN_API_VERSION, type AgentDevicePlugin } from './contract.ts'; + +/** The one validation of what a plugin factory returned, whichever source produced it. */ +export function validateAgentDevicePlugin(value: unknown): AgentDevicePlugin { + if (!isRecord(value)) throw invalidPlugin('Plugin factory must return a plugin object'); + if (value.apiVersion !== PROVIDER_PLUGIN_API_VERSION) { + throw new AppError('INVALID_ARGS', 'Plugin does not declare a compatible provider contract', { + reason: 'incompatible_plugin', + apiVersion: PROVIDER_PLUGIN_API_VERSION, + hint: `Install a plugin release supporting provider plugin apiVersion ${PROVIDER_PLUGIN_API_VERSION}.`, + }); + } + if (!isProviderId(value.provider)) { + throw invalidPlugin('Plugin provider must be a lowercase id such as "example"'); + } + if (ROUTING_PROVIDER_IDENTITIES.includes(value.provider)) { + throw new AppError('INVALID_ARGS', `Provider id is reserved for routing: ${value.provider}`, { + reason: 'reserved_provider', + provider: value.provider, + }); + } + assertCredentials(value.credentials); + if (value.connection !== undefined) assertConnection(value.connection); + if (typeof value.setup !== 'function') throw invalidPlugin('Plugin must provide setup(host)'); + return value as AgentDevicePlugin; +} + +function assertCredentials(credentials: unknown): void { + if (!isRecord(credentials) || !Array.isArray(credentials.variables)) { + throw invalidPlugin('Plugin credentials must declare variables'); + } + assertCredentialVariables(credentials.variables); + if (credentials.byBackend === undefined) return; + if (!isRecord(credentials.byBackend)) { + throw invalidPlugin('Plugin credentials byBackend must map a lease backend to variables'); + } + for (const names of Object.values(credentials.byBackend)) { + if (!Array.isArray(names)) { + throw invalidPlugin('Plugin credentials byBackend must map a lease backend to variables'); + } + assertCredentialVariables(names); + } +} + +function assertConnection(connection: unknown): void { + if ( + !isRecord(connection) || + connection.capabilities === undefined || + typeof connection.resolve !== 'function' || + typeof connection.verify !== 'function' + ) { + throw invalidPlugin('Plugin connection must provide capabilities, resolve and verify'); + } + assertLocalConnectionCapabilities(connection.capabilities as ConnectionProviderCapabilities); +} + +/** The runtime a plugin built must answer to the provider id it registered. */ +export function assertProviderRuntime( + provider: string, + runtime: ProviderDeviceRuntime | undefined, + platformModule: PlatformRuntimeProviderModule | undefined, +): void { + const owner = platformModule?.owner; + if ( + runtime?.provider !== provider || + typeof runtime.shutdown !== 'function' || + !hasProviderFacets(runtime) || + owner?.kind !== 'provider-runtime' || + owner.provider !== provider || + typeof owner.instance !== 'string' || + owner.instance.trim().length === 0 || + typeof platformModule?.loadRuntime !== 'function' + ) { + throw invalidPlugin(`Plugin runtime owner does not match its provider: ${provider}`); + } +} + +function hasProviderFacets(runtime: ProviderDeviceRuntime): boolean { + return ( + runtime.leaseLifecycle !== null && + typeof runtime.leaseLifecycle === 'object' && + (['ownsDevice', 'getInteractor', 'deviceInventoryProvider'] as const).every( + (method) => typeof runtime[method] === 'function', + ) + ); +} + +export function invalidPlugin(message: string): AppError { + return new AppError('INVALID_ARGS', message, { reason: 'invalid_plugin' }); +} diff --git a/packages/provider-webdriver/src/index.ts b/packages/provider-webdriver/src/index.ts index e80ef7ee86..f284472929 100644 --- a/packages/provider-webdriver/src/index.ts +++ b/packages/provider-webdriver/src/index.ts @@ -1,10 +1,6 @@ import { awsDeviceFarmProvider } from './aws-device-farm-provider.ts'; import { browserStackProvider } from './browserstack-provider.ts'; -import type { - BundledCloudWebDriverProvider, - CloudWebDriverProviderHost, -} from './provider-plugin.ts'; -import { createCloudWebDriverRuntime, type CloudWebDriverRuntime } from './runtime.ts'; +import type { BundledCloudWebDriverProvider } from './provider-plugin.ts'; export type { RunHostCommand } from './dependencies.ts'; export type { @@ -12,7 +8,6 @@ export type { CloudWebDriverConnection, CloudWebDriverProviderHost, } from './provider-plugin.ts'; -export type { CloudWebDriverRuntime } from './runtime.ts'; /** * The hosted WebDriver providers agent-device ships. Each presents the shape an installed plugin @@ -28,15 +23,3 @@ export function bundledCloudWebDriverProvider( (bundled) => bundled.declaration.provider === provider, ); } - -/** One runtime per bundled provider, composed the way an installed WebDriver plugin is. */ -export function createBundledCloudWebDriverRuntimes( - host: CloudWebDriverProviderHost, -): CloudWebDriverRuntime[] { - return BUNDLED_CLOUD_WEBDRIVER_PROVIDERS.map((bundled) => - createCloudWebDriverRuntime({ - ...bundled.create(host).webDriver, - clientVersion: host.clientVersion, - }), - ); -} diff --git a/packages/provider-webdriver/src/providers.ts b/packages/provider-webdriver/src/providers.ts index 4bf29d5dbd..dd0e6c5a46 100644 --- a/packages/provider-webdriver/src/providers.ts +++ b/packages/provider-webdriver/src/providers.ts @@ -36,7 +36,7 @@ const HOSTED_WEBDRIVER_CONNECTION: ConnectionProviderCapabilities = { usesCloudWebDriverLease: true, }; -const CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS: readonly CloudWebDriverProviderDeclaration[] = +export const CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS: readonly CloudWebDriverProviderDeclaration[] = Object.freeze([ { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 894670ba21..70c2167301 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -98,6 +98,8 @@ export { registrationSource, selectPlugin, selectPluginManifest, + selectV2Plugin, + v2RegistrationSource, webDriverPluginSource, writePlugin, } from './plugin.fixtures.ts'; diff --git a/packages/test-kit/src/plugin.fixtures.ts b/packages/test-kit/src/plugin.fixtures.ts index bce8780eac..dea3079ce5 100644 --- a/packages/test-kit/src/plugin.fixtures.ts +++ b/packages/test-kit/src/plugin.fixtures.ts @@ -61,11 +61,16 @@ export function selectPluginManifest( recordSelection(home, manifest.name, installation); } -function recordSelection(home: string, name: string, installation: string) { +function recordSelection( + home: string, + name: string, + installation: string, + options: unknown = { region: 'eu' }, +) { const configPath = path.join(home, 'config.json'); const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {}; config.plugins ??= {}; - config.plugins[name] = { installation, options: { region: 'eu' } }; + config.plugins[name] = { installation, options }; fs.writeFileSync(configPath, JSON.stringify(config)); } @@ -127,3 +132,64 @@ export function webDriverPluginSource( connection ? `, connection: ${connection}` : '' } });`; } + +/** + * A v2 plugin factory module. `extra` is spliced into the returned plugin object, so a test can add + * `connection` or override `credentials`; `shutdownFile` is written when the runtime shuts down. + */ +export function v2RegistrationSource( + provider: string, + options: { shutdownFile?: string; extra?: string; credentials?: string } = {}, +) { + return `import fs from 'node:fs'; + export default (options = {}) => ({ + apiVersion: 2, + provider: ${JSON.stringify(provider)}, + credentials: ${options.credentials ?? '{ variables: [] }'}, + ${options.extra ?? ''} + setup: async host => ({ + runtime: { provider: ${JSON.stringify(provider)}, + ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: {}, + shutdown: async () => { ${options.shutdownFile ? `fs.writeFileSync(${JSON.stringify(options.shutdownFile)}, 'shutdown');` : ''} }, + options, hostEnv: host.env }, + platformModule: { owner: { kind: 'provider-runtime', provider: ${JSON.stringify(provider)}, instance: 'test' }, + loadRuntime: async () => { throw new Error('lazy'); } } + }) + });`; +} + +/** Installs a v2 package: no `agentDevicePlugin` manifest, an `./agent-device` export, options recorded. */ +export function selectV2Plugin( + home: string, + name: string, + source: string, + options: { + exports?: unknown; + manifest?: Record; + files?: Record; + selectionOptions?: unknown; + } = {}, +) { + const installation = crypto.randomUUID(); + const directory = path.join(home, 'plugins', installation, 'node_modules', name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name, + version: '2.0.0', + type: 'module', + exports: + 'exports' in options ? options.exports : { './agent-device': { import: './plugin.mjs' } }, + ...options.manifest, + }), + ); + fs.writeFileSync(path.join(directory, 'plugin.mjs'), source); + for (const [file, contents] of Object.entries(options.files ?? {})) { + fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }); + fs.writeFileSync(path.join(directory, file), contents); + } + recordSelection(home, name, installation, options.selectionOptions); + return directory; +} diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index a4f6860006..6dbda672f6 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -91,6 +91,7 @@ "@agent-device/contracts/platform-runtime-host", "@agent-device/contracts/platform-runtime-operations", "@agent-device/contracts/platform-runtime-unavailable", + "@agent-device/contracts/plugin-inventory", "@agent-device/contracts/progress", "@agent-device/contracts/provider-profile-fields", "@agent-device/contracts/react-native-overlay", diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index bbd5ee733e..630cc40d5d 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -30,6 +30,8 @@ export const ROOT_MODULE_ZONES: Readonly> = { // construction, which names these same runtime families and keeps the same value-free direction // toward `packages/daemon/src/` (PR 6 of #3276's daemon extraction). 'platform-runtime': [ + 'src/builtin-provider-ids.ts', + 'src/builtin-provider-plugins.ts', 'src/daemon-composition.ts', 'src/platform-runtime-android-mechanics.ts', 'src/platform-runtime-android-observation-host.ts', diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts index 7d48c31ab3..892e39c851 100644 --- a/src/__tests__/cloud-connect-testmu.test.ts +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -13,9 +13,8 @@ import testMuPlugin from '@agent-device/testmu'; import { createPluginHost } from '@agent-device/plugins/host'; import { resolveConnectProviderProfile } from '../cli/connection/connect-provider-adapters.ts'; import { mkdtempForTestSync, pluginHome, selectPlugin } from '@agent-device/test-kit'; -import { installedPlugins } from '@agent-device/plugins/store'; import manifest from '@agent-device/testmu/package.json' with { type: 'json' }; -import type { PluginConnection } from '@agent-device/plugins/connection'; +import type { PluginConnectionCallbacks } from '@agent-device/plugins/connection'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; vi.mock('@agent-device/testmu/connection-verification', () => ({ @@ -24,9 +23,9 @@ vi.mock('@agent-device/testmu/connection-verification', () => ({ vi.mock('@agent-device/plugins/load', () => ({ withPluginConnection: async ( _provider: string, + _builtinProviders: readonly string[], env: NodeJS.ProcessEnv, - _policy: unknown, - runConnection: (connection: PluginConnection) => Promise, + runConnection: (connection: PluginConnectionCallbacks) => Promise, ) => { const registration = testMuPlugin(createPluginHost(env, undefined)); return await runConnection(registration.connection); @@ -41,10 +40,18 @@ afterEach(() => { const mockedVerifyWebDriverConnection = vi.mocked(verifyTestMuConnection); beforeEach(() => { - const { home, env } = pluginHome(); + const { home } = pluginHome(); selectPlugin(home, manifest.name, 'testmu', 'export default () => {};'); - const [plugin] = installedPlugins(env); - const manifestPath = path.join(plugin!.directory, 'package.json'); + const { installation } = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')) + .plugins[manifest.name]; + const manifestPath = path.join( + home, + 'plugins', + installation, + 'node_modules', + manifest.name, + 'package.json', + ); const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); declared.agentDevicePlugin.connection = manifest.agentDevicePlugin.connection; fs.writeFileSync(manifestPath, JSON.stringify(declared)); diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 8051c94daa..e1e64724e2 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -1,16 +1,13 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { RESERVED_PLUGIN_PROVIDERS } from '../provider-plugin-policy.ts'; -import { - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, - createDefaultProviderRuntimeComposition, - createDaemonProviderRuntimeComposition, -} from '../provider-device-runtimes.ts'; +import { createDaemonProviderRuntimeComposition } from '../provider-device-runtimes.ts'; +import fs from 'node:fs'; +import path from 'node:path'; import { pluginHome, registrationSource, selectPlugin, - webDriverPluginSource, + selectV2Plugin, } from '@agent-device/test-kit'; test('daemon composition registers installed provider runtimes and their lazy platform modules', async () => { @@ -23,31 +20,30 @@ test('daemon composition registers installed provider runtimes and their lazy pl composition.platformModules.find(({ runtime }) => runtime === plugin)?.module.owner.provider, 'example', ); - await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); + await composition.shutdown(); }); -test('daemon composition registers a WebDriver plugin through the shared runtime', async () => { +test('built-in providers register ahead of the store and an unconfigured Limrun still claims its id', async () => { const { home, env } = pluginHome(); - selectPlugin(home, 'example', 'example', webDriverPluginSource('example')); + selectPlugin(home, 'example', 'example', registrationSource('example')); const composition = await createDaemonProviderRuntimeComposition(env); - const plugin = composition.runtimes.find((runtime) => runtime.provider === 'example'); - assert.ok(plugin); - assert.equal( - composition.platformModules.find(({ runtime }) => runtime === plugin)?.module.owner.provider, - 'example', - ); - await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); -}); -test('bundled composition stays independent of configured plugins and reserves all builtin IDs', async () => { - const { home, env } = pluginHome(); - selectPlugin(home, 'example', 'example', 'throw new Error("must not evaluate");'); - const composition = await createDefaultProviderRuntimeComposition(env); - assert.ok(composition.runtimes.every(({ provider }) => provider !== 'example')); - await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); + assert.deepEqual( + composition.runtimes.map((runtime) => runtime.provider), + ['browserstack', 'aws-device-farm', 'example'], + ); + assert.deepEqual( + composition.inventory.map(({ provider, source, state }) => [provider, source, state]), + [ + ['browserstack', 'builtin', 'active'], + ['aws-device-farm', 'builtin', 'active'], + ['example', 'store', 'active'], + ], + ); + await composition.shutdown(); }); -test('daemon composition skips Limrun for a partial instance set and still loads every other provider', async () => { +test('daemon composition records Limrun as failed for a partial instance set and still loads every other provider', async () => { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', registrationSource('example')); const composition = await createDaemonProviderRuntimeComposition({ @@ -57,56 +53,72 @@ test('daemon composition skips Limrun for a partial instance set and still loads LIM_ANDROID_INSTANCE_TOKEN: 'android-instance-token', }); - const providers = composition.runtimes.map((runtime) => runtime.provider); - assert.deepEqual(providers, ['browserstack', 'aws-device-farm', 'example']); - assert.equal(composition.skipped?.[0]?.provider, 'limrun'); - assert.equal(composition.skipped?.[0]?.error.code, 'INVALID_ARGS'); - assert.match(composition.skipped?.[0]?.error.message ?? '', /LIM_ANDROID_INSTANCE_ADB_URL/); - await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); + assert.deepEqual( + composition.runtimes.map((runtime) => runtime.provider), + ['browserstack', 'aws-device-farm', 'example'], + ); + const limrun = composition.inventory.find(({ provider }) => provider === 'limrun'); + assert.equal(limrun?.state, 'failed'); + assert.equal(limrun?.error?.code, 'INVALID_ARGS'); + assert.match(limrun?.error?.message ?? '', /LIM_ANDROID_INSTANCE_ADB_URL/); + await composition.shutdown(); }); -test('daemon composition rejects builtin provider IDs before evaluating plugins', async () => { +test('a plugin claiming a built-in provider ID is recorded as failed and the built-in keeps the ID', async () => { for (const provider of ['limrun', 'browserstack', 'aws-device-farm']) { const { home, env } = pluginHome(); selectPlugin(home, 'example', provider, 'throw new Error("must not evaluate");'); - await assert.rejects(createDaemonProviderRuntimeComposition(env), { code: 'INVALID_ARGS' }); + const composition = await createDaemonProviderRuntimeComposition(env); + const plugin = composition.inventory.find(({ package: name }) => name === 'example'); + assert.equal(plugin?.state, 'failed'); + assert.equal(plugin?.error?.reason, 'duplicate_provider'); + // An unconfigured Limrun claims its id without registering a runtime. + assert.equal( + composition.runtimes.filter((runtime) => runtime.provider === provider).length, + provider === 'limrun' ? 0 : 1, + ); + await composition.shutdown(); } }); -test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => { - const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ - LIM_API_KEY: 'lim_test_key', - }); +test('Limrun stays unregistered when only the removed API key alias is configured', async () => { + const { env } = pluginHome(); + const { runtimes, platformModules, inventory, shutdown } = + await createDaemonProviderRuntimeComposition({ + ...env, + LIM_API_KEY: 'lim_test_key', + }); assert.equal( runtimes.some((runtime) => runtime.provider === 'limrun'), false, ); + assert.equal( + inventory.some(({ provider }) => provider === 'limrun'), + false, + ); assertPlatformModuleCoverage(runtimes, platformModules); - await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); + await shutdown(); }); -test('default provider runtimes load Limrun when a Limrun API key is configured', async () => { - const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ +test('Limrun registers when a Limrun API key is configured', async () => { + const { env } = pluginHome(); + const { runtimes, platformModules, shutdown } = await createDaemonProviderRuntimeComposition({ + ...env, LIMRUN_API_KEY: 'lim_test_key', }); - assert.equal( - runtimes.some((runtime) => runtime.provider === 'limrun'), - true, - ); const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); - assert.equal(limrun ? 'loadRuntime' in limrun : true, false); - assertPlatformModuleCoverage(runtimes, platformModules, [limrun!]); - assert.equal( - platformModules.some(({ runtime }) => runtime === limrun), - true, - ); - await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); + assert.ok(limrun); + assert.equal('loadRuntime' in limrun, false); + assertPlatformModuleCoverage(runtimes, platformModules, [limrun]); + await shutdown(); }); -test('default provider runtimes load Limrun for an existing instance without an API key', async () => { - const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ +test('Limrun registers for an existing instance without an API key', async () => { + const { env } = pluginHome(); + const { runtimes, platformModules, shutdown } = await createDaemonProviderRuntimeComposition({ + ...env, LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', LIM_IOS_INSTANCE_TOKEN: 'ios-instance-token', }); @@ -114,7 +126,7 @@ test('default provider runtimes load Limrun for an existing instance without an const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); assert.ok(limrun); assertPlatformModuleCoverage(runtimes, platformModules, [limrun]); - await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); + await shutdown(); }); function assertPlatformModuleCoverage( @@ -136,7 +148,29 @@ function assertPlatformModuleCoverage( } } -test('every bundled provider runtime is reserved from plugins', () => { - for (const provider of DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS) - assert.ok((RESERVED_PLUGIN_PROVIDERS as readonly string[]).includes(provider), provider); +test('composition shutdown is the only route the daemon uses: it shuts every runtime down and runs plugin cleanup', async () => { + const { home, env } = pluginHome(); + const log = path.join(home, 'shutdown-log'); + selectV2Plugin( + home, + 'cleaning', + `import fs from 'node:fs'; + export default (options) => ({ + apiVersion: 2, provider: 'cleaning', credentials: { variables: [] }, + setup: async () => ({ + runtime: { provider: 'cleaning', ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: {}, + shutdown: async () => { fs.appendFileSync(options.log, 'runtime;'); } }, + platformModule: { owner: { kind: 'provider-runtime', provider: 'cleaning', instance: 'test' }, loadRuntime: async () => { throw new Error('lazy'); } }, + dispose: async () => { fs.appendFileSync(options.log, 'dispose;'); }, + }), + });`, + { selectionOptions: { log } }, + ); + const composition = await createDaemonProviderRuntimeComposition(env); + assert.ok(composition.runtimes.some((runtime) => runtime.provider === 'cleaning')); + + await composition.shutdown(); + + assert.equal(fs.readFileSync(log, 'utf8'), 'runtime;dispose;'); }); diff --git a/src/__tests__/test-utils/builtin-plugin-inventory.ts b/src/__tests__/test-utils/builtin-plugin-inventory.ts new file mode 100644 index 0000000000..eac4a34b3d --- /dev/null +++ b/src/__tests__/test-utils/builtin-plugin-inventory.ts @@ -0,0 +1,14 @@ +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; +import { resolveProviderPlugins } from '@agent-device/plugins/registry'; +import { builtinProviderCandidates } from '../../builtin-provider-plugins.ts'; + +/** + * The inventory the built-in providers have after load and validation, with every one of them + * configured. It does not include setup failures, which only a started registry records. + */ +export async function builtinPluginInventory(): Promise { + const resolved = await resolveProviderPlugins( + builtinProviderCandidates({ LIMRUN_API_KEY: 'configured' }), + ); + return resolved.inventory(); +} diff --git a/src/builtin-provider-ids.ts b/src/builtin-provider-ids.ts new file mode 100644 index 0000000000..3f547a4cb5 --- /dev/null +++ b/src/builtin-provider-ids.ts @@ -0,0 +1,37 @@ +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; +import { ROUTING_PROVIDER_IDENTITIES } from '@agent-device/plugins/manifest'; +import { CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS } from '@agent-device/provider-webdriver/providers'; + +export const LIMRUN_ID = 'limrun'; + +const LIMRUN_CONNECTION: ConnectionProviderCapabilities = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: true, + supportsDirectPortReverse: true, + usesCloudWebDriverLease: false, +}; + +/** Every provider id agent-device registers itself; a plugin cannot claim any of them. */ +export const BUILTIN_PROVIDER_IDS: readonly string[] = [ + ...CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS.map(({ provider }) => provider), + LIMRUN_ID, +]; + +/** Provider ids `connect` accepts without a plugin: the routing identities and the built-ins. */ +export const BUILTIN_CONNECT_PROVIDER_IDS: readonly string[] = [ + ...ROUTING_PROVIDER_IDENTITIES, + ...BUILTIN_PROVIDER_IDS, +]; + +/** The built-in provider's connect capabilities, read without loading its implementation. */ +export function builtinConnectionCapabilities( + provider: string | undefined, +): ConnectionProviderCapabilities | undefined { + if (provider === LIMRUN_ID) return LIMRUN_CONNECTION; + return CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS.find( + (declaration) => declaration.provider === provider, + )?.connection; +} diff --git a/src/builtin-provider-plugins.ts b/src/builtin-provider-plugins.ts new file mode 100644 index 0000000000..1d09697d39 --- /dev/null +++ b/src/builtin-provider-plugins.ts @@ -0,0 +1,94 @@ +import type { AgentDevicePlugin } from '@agent-device/plugins/contract'; +import type { PluginCandidate } from '@agent-device/plugins/registry'; +import { + CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS, + type CloudWebDriverProviderDeclaration, +} from '@agent-device/provider-webdriver/providers'; +import { LIMRUN_ID } from './builtin-provider-ids.ts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import { + limrunCredentialDeclaration, + readLimrunCredentials, + type LimrunCredentials, +} from './provider-limrun-credentials.ts'; + +/** + * The built-in providers as registry candidates, in the shape a plugin has. Their implementations + * load only when a runtime is set up; a candidate that needs configuration claims its id without + * registering until the environment holds it. + */ +export function builtinProviderCandidates(env: EnvMap): PluginCandidate[] { + return [ + ...CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS.map((declaration): PluginCandidate => ({ + source: 'builtin', + provider: declaration.provider, + load: () => cloudWebDriverPlugin(declaration), + })), + limrunCandidate(env), + ]; +} + +function cloudWebDriverPlugin(declaration: CloudWebDriverProviderDeclaration): AgentDevicePlugin { + const loadProvider = async () => { + const [{ bundledCloudWebDriverProvider }, host] = await Promise.all([ + import('@agent-device/provider-webdriver'), + import('./provider-webdriver.ts'), + ]); + return { bundled: bundledCloudWebDriverProvider(declaration.provider)!, host }; + }; + return { + apiVersion: 2, + provider: declaration.provider, + credentials: { variables: declaration.credentialVariables ?? [] }, + connection: { + capabilities: declaration.connection, + resolve: async (context) => { + const { bundled, host } = await loadProvider(); + return await bundled + .create(host.createBundledCloudWebDriverHost(context.env)) + .connection.resolve(context); + }, + verify: async (context) => { + const { bundled, host } = await loadProvider(); + return await bundled + .create(host.createBundledCloudWebDriverHost(context.env)) + .connection.verify(context); + }, + }, + setup: async (pluginHost) => { + const { bundled, host } = await loadProvider(); + return { + webDriver: bundled.create(host.createBundledCloudWebDriverHost(pluginHost.env)).webDriver, + }; + }, + }; +} + +function limrunCandidate(env: EnvMap): PluginCandidate { + const base = { source: 'builtin', provider: LIMRUN_ID } as const; + let credentials: LimrunCredentials | undefined; + try { + credentials = readLimrunCredentials(env); + } catch (error) { + return { ...base, load: () => Promise.reject(error) }; + } + if (!credentials) return { ...base, dormant: true, load: () => undefined }; + const limrun = credentials; + return { + ...base, + load: (): AgentDevicePlugin => ({ + apiVersion: 2, + provider: LIMRUN_ID, + credentials: limrunCredentialDeclaration(), + setup: async () => { + const [runtime, dependencies] = await Promise.all([ + import('@agent-device/provider-limrun'), + import('./sdk/limrun-runtime-dependencies.ts'), + ]); + return runtime.createLimrunRuntime(limrun, dependencies.createLimrunRuntimeDependencies(), { + includePlatformModule: true, + }); + }, + }), + }; +} diff --git a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts new file mode 100644 index 0000000000..9eca35ee0c --- /dev/null +++ b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from 'vitest'; +import fs from 'node:fs'; +import { pluginHome, selectV2Plugin, v2RegistrationSource } from '@agent-device/test-kit'; +import { + connectionWorkspace, + createTestClient, +} from '../../__tests__/remote-connection.fixtures.ts'; +import { materializeRemoteConnectionForCommand } from './connection-runtime.ts'; + +const LOCAL_CAPABILITIES = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, +}; + +test('a stored profile for a v2 plugin provider needs no remote daemon URL', async () => { + const { home } = pluginHome(); + vi.stubEnv('AGENT_DEVICE_HOME', home); + selectV2Plugin( + home, + 'example-v2', + v2RegistrationSource('example-v2', { + extra: `connection: { capabilities: ${JSON.stringify(LOCAL_CAPABILITIES)}, resolve() {}, verify() {} },`, + }), + ); + const { stateDir, remoteConfigPath } = connectionWorkspace('plugin-capabilities-'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + tenant: 'team', + runId: 'run-1', + leaseProvider: 'example-v2', + leaseBackend: 'android-instance', + platform: 'android', + }), + ); + + // Without the plugin's declared capabilities the profile reads as a remote provider and is + // refused for lacking a daemonBaseUrl. + await expect( + materializeRemoteConnectionForCommand({ + command: 'snapshot', + client: createTestClient(), + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'plugin-capabilities', + platform: 'android', + }, + }), + ).resolves.toBeDefined(); + vi.unstubAllEnvs(); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 50de3a2dec..d14935d03f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -34,7 +34,10 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; +import { + connectionProviderCapabilities, + primeProviderConnection, +} from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; import { @@ -102,6 +105,7 @@ export async function materializeRemoteConnectionForCommand(options: { ); } + await primeProviderConnection(providerOf(existingState, remoteConfig.profile)); const state = existingState ?? createRemoteConnectionStateFromFlags( @@ -819,6 +823,14 @@ function selectCompatibleRuntime( return isRuntimeCompatibleWithPlatform(runtime, platform) ? runtime : undefined; } +// A stored connection names its provider; before one exists, the remote config profile does. +function providerOf( + existingState: Pick | null | undefined, + profile: Pick, +): string | undefined { + return existingState?.leaseProvider ?? profile.leaseProvider; +} + function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 22b07570e8..dad3790de9 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -22,6 +22,7 @@ import { connectProviderNamesForError, connectionProviderCapabilities, isConnectProviderName, + primeProviderConnection, type ConnectProvider, } from '../connection/provider-policy.ts'; import { @@ -54,6 +55,7 @@ import { export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + await primeProviderConnection(positionals[0]); const provider = readConnectProvider(positionals); assertConnectProviderUsage(provider, flags); const resolved = await resolveConnectProviderProfile({ provider, flags, stateDir }); @@ -343,6 +345,7 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla await writeNoRemoteConnectionOutput(flags, session); return true; } + await primeProviderConnection(state.leaseProvider); const leasePreparation = buildLeasePreparationNotice(state); const runtimePreparation = buildRuntimePreparationNoticeFromState(state); await writeCommandOutput(flags, serializeConnectionState({ state, runtimePreparation }), () => diff --git a/src/cli/commands/plugins.ts b/src/cli/commands/plugins.ts index be152f6871..15cedede8f 100644 --- a/src/cli/commands/plugins.ts +++ b/src/cli/commands/plugins.ts @@ -1,6 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { changePlugin, listPlugins } from '@agent-device/plugins/store'; -import { PROVIDER_PLUGIN_POLICY } from '../../provider-plugin-policy.ts'; +import { BUILTIN_PROVIDER_IDS } from '../../builtin-provider-ids.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -21,16 +21,18 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags action as 'add' | 'update' | 'remove', name, process.env, - PROVIDER_PLUGIN_POLICY.reservedProviders, + BUILTIN_PROVIDER_IDS, ); } - const plugins = listPlugins(process.env); + const plugins = await listPlugins(process.env, BUILTIN_PROVIDER_IDS); await writeCommandOutput(flags, { plugins, restartRequired: action !== 'list' }, () => [ ...plugins.map((plugin) => plugin.compatible ? `${plugin.name}@${plugin.version} (${plugin.provider})` - : `${plugin.name}: unavailable; run plugins update ${plugin.name} or plugins remove ${plugin.name}`, + : plugin.name === undefined + ? `Plugin config unavailable: ${plugin.error?.message}` + : `${plugin.name}: unavailable; run plugins update ${plugin.name} or plugins remove ${plugin.name}`, ), ...(action !== 'list' ? ['Restart the local daemon after closing active sessions to use the changed plugin set.'] diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index a9087c699c..0b078c9cda 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -13,11 +13,15 @@ import { resolveLimrunConnectProfile } from './limrun-profile.ts'; import { readLimrunCredentials } from '../../provider-limrun-credentials.ts'; import { resolveProxyConnectProfile } from './proxy-profile.ts'; import { profileToCliFlags } from '../remote-config-flags.ts'; -import { isConnectProviderName, type ConnectProvider } from './provider-policy.ts'; +import { + isConnectProviderName, + primeProviderConnection, + type ConnectProvider, +} from './provider-policy.ts'; +import { BUILTIN_PROVIDER_IDS } from '../../builtin-provider-ids.ts'; import { bundledCloudWebDriverConnection } from '../../provider-webdriver.ts'; import { withPluginConnection } from '@agent-device/plugins/load'; -import { PROVIDER_PLUGIN_POLICY } from '../../provider-plugin-policy.ts'; -import type { PluginConnection } from '@agent-device/plugins/connection'; +import type { PluginConnectionCallbacks } from '@agent-device/plugins/connection'; import { readMetroProfileFields } from './profile-fields.ts'; import { buildConnectClientId } from './client-id.ts'; import { persistAndResolveGeneratedProfile } from './generated-config.ts'; @@ -86,12 +90,12 @@ function builtinConnectAdapter(provider: string): ConnectProviderAdapter | undef async function withProviderConnection( provider: string, env: EnvMap, - run: (connection: PluginConnection) => Promise, + run: (connection: PluginConnectionCallbacks) => Promise, ): Promise { const bundled = bundledCloudWebDriverConnection(provider, env); return bundled ? await run(bundled) - : await withPluginConnection(provider, env, PROVIDER_PLUGIN_POLICY, run); + : await withPluginConnection(provider, BUILTIN_PROVIDER_IDS, env, run); } export async function resolveConnectProviderProfile(options: { @@ -110,6 +114,7 @@ export async function resolveConnectProviderProfile(options: { env, }); const leaseProvider = resolved.profile.leaseProvider; + await primeProviderConnection(leaseProvider, env); return { flags: { ...profileToCliFlags(resolved.profile), @@ -145,7 +150,7 @@ export async function resolveConnectProviderProfile(options: { */ async function resolveProviderConnectionProfile( provider: string, - connection: PluginConnection, + connection: PluginConnectionCallbacks, context: AdapterContext, ): Promise { const resolved = await connection.resolve(context); diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index 64e09ea96b..70c76430e5 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,10 +1,20 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { test } from 'vitest'; -import { connectionProviderCapabilities, isConnectProviderName } from './provider-policy.ts'; -import { pluginHome, selectPlugin } from '@agent-device/test-kit'; -import { installedPlugins } from '@agent-device/plugins/store'; +import { expect, test } from 'vitest'; +import { pluginConnectionNames, primePluginConnections } from '@agent-device/plugins/connection'; +import { + connectionProviderCapabilities, + connectProviderNamesForError, + isConnectProviderName, + primeProviderConnection, +} from './provider-policy.ts'; +import { + pluginHome, + selectPlugin, + selectV2Plugin, + v2RegistrationSource, +} from '@agent-device/test-kit'; test('provider policy projects provider identity into semantic capabilities', () => { assert.deepEqual(connectionProviderCapabilities('limrun'), { @@ -26,7 +36,14 @@ test('provider policy projects provider identity into semantic capabilities', () test('plugin capabilities come from the same environment as the provider name check', () => { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', 'export default () => {};'); - const manifestPath = path.join(installedPlugins(env)[0]!.directory, 'package.json'); + const { installation } = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')) + .plugins.example; + const manifestPath = path.join( + home, + 'plugins', + installation, + 'node_modules/example/package.json', + ); const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const connection = { leaseKind: 'direct-device-provider', @@ -43,3 +60,60 @@ test('plugin capabilities come from the same environment as the provider name ch assert.equal(isConnectProviderName('example', env), true); assert.deepEqual(connectionProviderCapabilities('example', env), connection); }); + +test('a v2 plugin declares its capabilities in code, so they are read once the store is primed', async () => { + const { home, env } = pluginHome(); + const connection = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + } as const; + selectV2Plugin( + home, + 'example-v2', + v2RegistrationSource('example-v2', { + extra: `connection: { capabilities: ${JSON.stringify(connection)}, resolve() {}, verify() {} },`, + }), + ); + + assert.equal(isConnectProviderName('example-v2', env), false); + await primeProviderConnection('example-v2', env); + assert.equal(isConnectProviderName('example-v2', env), true); + assert.deepEqual(connectionProviderCapabilities('example-v2', env), connection); +}); + +test('a store plugin that fails to load is named where an unknown connect provider is refused', async () => { + const { home, env } = pluginHome(); + selectV2Plugin(home, 'broken-v2', 'throw new Error("import failed");'); + await primeProviderConnection('broken-v2', env); + expect(connectProviderNamesForError(env)).toMatch( + /Plugins that failed to load: broken-v2: import failed/, + ); +}); + +test('once the store is primed a duplicate v1 manifest no longer supplies capabilities', async () => { + const { home, env } = pluginHome(); + selectPlugin(home, 'dup', 'browserstack', 'export default () => {};'); + const { installation } = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')) + .plugins.dup; + const manifestPath = path.join(home, 'plugins', installation, 'node_modules/dup/package.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + declared.agentDevicePlugin.connection = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: true, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + }; + fs.writeFileSync(manifestPath, JSON.stringify(declared)); + + expect(pluginConnectionNames(env)).toEqual(['browserstack']); + await primePluginConnections(env, ['browserstack']); + expect(pluginConnectionNames(env)).toEqual([]); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 75117c7b89..7ceb6fbfa7 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -1,23 +1,17 @@ import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; -import { cloudWebDriverProviderDeclaration } from '@agent-device/provider-webdriver/providers'; import { pluginConnectionCapabilities, + pluginLoadFailures, pluginConnectionNames, + primePluginConnections, } from '@agent-device/plugins/connection'; -import { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../provider-plugin-policy.ts'; - -export type BuiltinConnectProvider = (typeof BUILTIN_CONNECT_PROVIDERS)[number]; -export type ConnectProvider = BuiltinConnectProvider | (string & {}); +import { + BUILTIN_CONNECT_PROVIDER_IDS, + BUILTIN_PROVIDER_IDS, + builtinConnectionCapabilities, +} from '../../builtin-provider-ids.ts'; -const LIMRUN_CONNECTION: ConnectionProviderCapabilities = { - leaseKind: 'direct-device-provider', - requiresAppAttachment: false, - requiresRemoteDaemon: false, - supportsArtifacts: false, - supportsDeferredAppSelection: true, - supportsDirectPortReverse: true, - usesCloudWebDriverLease: false, -}; +export type ConnectProvider = string; function remoteDaemonConnection( leaseKind: 'proxy' | 'remote-provider', @@ -33,21 +27,38 @@ function remoteDaemonConnection( }; } +/** + * Loads the plugin store before the first synchronous capability lookup for a provider that is not + * built in, because a v2 plugin declares its capabilities in code, not in its manifest. + */ +export async function primeProviderConnection( + provider: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (provider === undefined || BUILTIN_CONNECT_PROVIDER_IDS.includes(provider)) return; + await primePluginConnections(env, BUILTIN_PROVIDER_IDS); +} + export function isConnectProviderName( value: string | undefined, env: NodeJS.ProcessEnv = process.env, ): value is ConnectProvider { return ( - value === 'cloud' || - value === 'proxy' || - value === 'limrun' || - cloudWebDriverProviderDeclaration(value) !== undefined || - pluginConnectionCapabilities(value, env) !== undefined + value !== undefined && + (BUILTIN_CONNECT_PROVIDER_IDS.includes(value) || + pluginConnectionCapabilities(value, env) !== undefined) ); } -export function connectProviderNamesForError(): string { - return [...BUILTIN_CONNECT_PROVIDERS, ...pluginConnectionNames()].join(', '); +export function connectProviderNamesForError(env: NodeJS.ProcessEnv = process.env): string { + const names = [...BUILTIN_CONNECT_PROVIDER_IDS, ...pluginConnectionNames(env)].join(', '); + const failures = pluginLoadFailures(env).map( + ({ package: name, provider, error }) => + `${name ?? provider ?? 'plugin store'}: ${error.message}`, + ); + return failures.length === 0 + ? names + : `${names}. Plugins that failed to load: ${failures.join('; ')}`; } /** Bundled and installed providers declare their capabilities; the rest is the daemon's own routing. */ @@ -55,10 +66,9 @@ export function connectionProviderCapabilities( provider: string | undefined, env: NodeJS.ProcessEnv = process.env, ): ConnectionProviderCapabilities { - if (provider === 'limrun') return LIMRUN_CONNECTION; if (provider === 'proxy') return remoteDaemonConnection('proxy'); const declared = - cloudWebDriverProviderDeclaration(provider)?.connection ?? + builtinConnectionCapabilities(provider) ?? (provider === 'cloud' ? undefined : pluginConnectionCapabilities(provider, env)); return declared ?? remoteDaemonConnection('remote-provider'); } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 4413e79e60..4629938f10 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -664,7 +664,7 @@ Rules: Use Limrun, BrowserStack, AWS Device Farm, and TestMu AI through local provider profiles; they do not accept a remote agent-device daemon URL. Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. TestMu AI uses LT_USERNAME and LT_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. A local daemon keeps the Limrun, BrowserStack, and TestMu AI credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare. - A lease for a provider the daemon has no runtime for refuses with UNSUPPORTED_OPERATION, reason provider-not-registered, and lists the registered providers; no lease is recorded. Restart the daemon with the provider configured. Provider-less leases, proxy, and cloud need no provider runtime. + A lease for a provider the daemon has no runtime for refuses with UNSUPPORTED_OPERATION, reason provider-not-registered, and lists the registered providers, plus the plugins that failed to register as failedPlugins; no lease is recorded. Restart the daemon with the provider configured. Provider-less leases, proxy, and cloud need no provider runtime. Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session. connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. diff --git a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts index dd0fcb6a26..c3e2798d3e 100644 --- a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts @@ -15,9 +15,15 @@ import { mkdtempForTestSync, supportsLoopbackBind, } from '@agent-device/test-kit'; -import { providerCredentialFingerprint } from '../../provider-credential-fingerprint.ts'; +import { providerCredentialFingerprint as fingerprintFor } from '../../provider-credential-fingerprint.ts'; +import { builtinPluginInventory } from '../../__tests__/test-utils/builtin-plugin-inventory.ts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts'; +const inventory = await builtinPluginInventory(); +const providerCredentialFingerprint = (provider: string, env: EnvMap, leaseBackend?: string) => + fingerprintFor(provider, env, leaseBackend, inventory); + const API_KEY = 'lim-secret-key'; const ANDROID_ATTACH = { LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', @@ -170,6 +176,7 @@ function writeDaemonInfo(stateDir: string, endpoint: Record): v paths.infoPath, `${JSON.stringify({ ...endpoint, + plugins: inventory, token: 'local-secret', pid: process.pid, version: readVersion(), diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 785666401f..4709c85ced 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -24,6 +24,8 @@ export type DaemonInfo = { policyDigest?: string; /** Absent from daemons that predate capabilities, which therefore have none. */ capabilities?: readonly DaemonCapability[]; + /** The provider plugin inventory as published; `readPluginInventory` validates it where it is read. */ + plugins?: unknown; baseUrl?: string; remoteInstanceId?: string; remoteUpstreamInstanceId?: string; @@ -53,6 +55,7 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { processStartTime: readOptionalString(parsed.processStartTime), policyDigest: readOptionalString(parsed.policyDigest), capabilities: readDaemonCapabilities(parsed.capabilities), + ...(Array.isArray(parsed.plugins) ? { plugins: parsed.plugins } : {}), }; } diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 6243e39c8d..c65f528c0b 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -174,8 +174,16 @@ async function readLocalProviderCredentialFingerprint( if (isRemoteDaemon(info) || request.command !== 'lease_allocate') return undefined; const { leaseProvider: provider, leaseBackend } = leaseScopeFromRequest(request); if (!provider) return undefined; - const { providerCredentialFingerprint } = await import('../provider-credential-fingerprint.ts'); - return providerCredentialFingerprint(provider, process.env, leaseBackend); + const [{ providerCredentialFingerprint }, { readPluginInventory }] = await Promise.all([ + import('../provider-credential-fingerprint.ts'), + import('@agent-device/contracts/plugin-inventory'), + ]); + return providerCredentialFingerprint( + provider, + process.env, + leaseBackend, + readPluginInventory(info.plugins), + ); } // A developer dir is a path on the client's host, so only a local daemon can use it. diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts index a56d399df0..79ec9fc25c 100644 --- a/src/daemon-composition-credentials.test.ts +++ b/src/daemon-composition-credentials.test.ts @@ -5,6 +5,7 @@ import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolu import { mkdtempForTestSync } from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { providerCredentialFingerprint } from './provider-credential-fingerprint.ts'; +import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; vi.mock('./platform-runtime.ts', () => ({ androidObservation: {}, @@ -23,13 +24,12 @@ vi.mock('./platform-runtime.ts', () => ({ bind: async () => { throw new Error('unused'); }, - shutdown: async () => {}, + shutdown: async () => [], }), createPlatformDeviceInventoryGateways: () => ({}), })); -vi.mock('./provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], +vi.mock('./provider-device-runtimes.ts', async () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [ { @@ -42,11 +42,16 @@ vi.mock('./provider-device-runtimes.ts', () => ({ }, ], platformModules: [], + shutdown: async () => [], + inventory: await ( + await import('./__tests__/test-utils/builtin-plugin-inventory.ts') + ).builtinPluginInventory(), }), })); test('daemon startup compares lease credentials with its own startup environment', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); + const inventory = await builtinPluginInventory(); const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; const runtime = await startDaemonRuntime({ env: { @@ -63,9 +68,11 @@ test('daemon startup compares lease credentials with its own startup environment composeDeps: createDaemonRuntimeDeps, }); try { - const { httpPort, token } = JSON.parse( + const { httpPort, token, plugins } = JSON.parse( fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'), - ) as { httpPort: number; token: string }; + ) as { httpPort: number; token: string; plugins: unknown }; + // The composed inventory is what the daemon publishes in its own record. + expect(plugins).toEqual(inventory); const allocate = async (env: Record) => await ( await fetch(`http://127.0.0.1:${httpPort}/rpc`, { @@ -80,7 +87,12 @@ test('daemon startup compares lease credentials with its own startup environment tenantId: 'tenant', runId: 'run', leaseProvider: 'browserstack', - providerCredentialFingerprint: providerCredentialFingerprint('browserstack', env), + providerCredentialFingerprint: providerCredentialFingerprint( + 'browserstack', + env, + undefined, + inventory, + ), }, }), }) @@ -89,9 +101,7 @@ test('daemon startup compares lease credentials with its own startup environment expect(await allocate({ ...daemonEnv, BROWSERSTACK_ACCESS_KEY: 'key-2' })).toContain( 'provider-credentials-changed', ); - const accepted = await allocate(daemonEnv); - expect(accepted).not.toContain('provider-credentials-changed'); - expect(accepted).toContain('leaseId'); + expect(await allocate(daemonEnv)).not.toContain('provider-credentials-changed'); } finally { await runtime?.shutdown(); } diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts index 71435dfea3..f211c8ba6d 100644 --- a/src/daemon-composition.test.ts +++ b/src/daemon-composition.test.ts @@ -5,6 +5,11 @@ import { IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { setActiveProviderDeviceRuntimes } from './provider-device-runtime.ts'; +const ports = vi.hoisted(() => ({ + inventoryGateways: vi.fn(async (): Promise => ({})), + shutdownProviders: vi.fn(async () => []), +})); + vi.mock('./platform-runtime.ts', () => ({ androidObservation: {}, createRequestPlatformProviders: () => ({ @@ -22,18 +27,23 @@ vi.mock('./platform-runtime.ts', () => ({ bind: async () => { throw new Error('unused'); }, - shutdown: async () => {}, + shutdown: async () => [], }), - createPlatformDeviceInventoryGateways: () => ({}), + createPlatformDeviceInventoryGateways: ports.inventoryGateways, })); vi.mock('./provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ + runtimes: [], + platformModules: [], + shutdown: ports.shutdownProviders, + inventory: [], + }), })); afterEach(() => { setActiveProviderDeviceRuntimes([]); + vi.clearAllMocks(); }); test('the composed interactor resolution resolves the interactor of the provider that owns the device', async () => { @@ -63,3 +73,43 @@ test('the composed interactor resolution resolves the interactor of the provider providerInteractor, ); }); + +test('a composition step that fails after the plugins were set up closes them once and hands nothing over', async () => { + const baseDir = mkdtempForTestSync('agent-device-daemon-composition-rollback-'); + ports.inventoryGateways.mockRejectedValueOnce(new Error('inventory gateways failed')); + + await expect( + createDaemonRuntimeDeps({ + env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + baseDir, + sessionsDir: `${baseDir}/sessions`, + assertShutdownAllowed: undefined, + resolveSessionDir: (sessionId) => `${baseDir}/sessions/${sessionId}`, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + openWebSessionNames: () => [], + }), + ).rejects.toThrow('inventory gateways failed'); + + expect(ports.shutdownProviders).toHaveBeenCalledOnce(); +}); + +test('a composition that succeeds leaves the plugins open for the daemon to close', async () => { + const baseDir = mkdtempForTestSync('agent-device-daemon-composition-owned-'); + const deps = await createDaemonRuntimeDeps({ + env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + baseDir, + sessionsDir: `${baseDir}/sessions`, + assertShutdownAllowed: undefined, + resolveSessionDir: (sessionId) => `${baseDir}/sessions/${sessionId}`, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + openWebSessionNames: () => [], + }); + + expect(ports.shutdownProviders).not.toHaveBeenCalled(); + await deps.shutdownProviderRuntimes(); + expect(ports.shutdownProviders).toHaveBeenCalledOnce(); +}); diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index d0c5fa3ccd..4a5942ddfa 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -17,10 +17,7 @@ import { createProviderDeviceRuntimeRequestProviders, isActiveProviderDevice, } from './provider-device-runtime.ts'; -import { - createDaemonProviderRuntimeComposition, - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, -} from './provider-device-runtimes.ts'; +import { createDaemonProviderRuntimeComposition } from './provider-device-runtimes.ts'; import { readDaemonProviderCredentials } from './provider-credential-fingerprint.ts'; import { getInteractor } from './core/interactors.ts'; import { @@ -90,72 +87,81 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( }); const hostDiagnostics = createHostDiagnostics(); const providerComposition = await createDaemonProviderRuntimeComposition(env); - const providerDeviceRuntimes = [...providerComposition.runtimes]; - const deviceRuntimeGateway = createPlatformRuntimeGateway({ - assertShutdownAllowed, - providerRuntimes: providerDeviceRuntimes, - providerModules: providerComposition.platformModules, - sessionsDir, - ownedProcesses: ownedProcessRecords, - resolveSessionArtifacts, - }); - const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders( - providerDeviceRuntimes, - { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, - ); - const requestPlatformProviders = createRequestPlatformProviders({ - providers: { - appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, - appleRunnerScreenRecordingTransport: - providerRuntimeProviders.appleRunnerScreenRecordingTransport, - }, - defaultWebProvider: { - stateDir: baseDir, - openWebSessionNames, - ownedProcessRecords, - }, - }); - const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( - providerRuntimeProviders.deviceInventorySource, - ); - // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. - // platform-runtime.ts is already static in this module, so the deferred imports keep these thin - // composition modules (and the readiness/open-target adapters only the services module pulls) - // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. - const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); - const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); + // The registry has set up every plugin. Until this function returns, nothing else owns its + // cleanup, so any later failure closes it here; success transfers ownership to the daemon. + try { + const providerDeviceRuntimes = [...providerComposition.runtimes]; + const deviceRuntimeGateway = createPlatformRuntimeGateway({ + assertShutdownAllowed, + providerRuntimes: providerDeviceRuntimes, + providerModules: providerComposition.platformModules, + sessionsDir, + ownedProcesses: ownedProcessRecords, + resolveSessionArtifacts, + }); + const providerRuntimeProviders = + createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); + const requestPlatformProviders = createRequestPlatformProviders({ + providers: { + appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, + appleRunnerScreenRecordingTransport: + providerRuntimeProviders.appleRunnerScreenRecordingTransport, + }, + defaultWebProvider: { + stateDir: baseDir, + openWebSessionNames, + ownedProcessRecords, + }, + }); + const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( + providerRuntimeProviders.deviceInventorySource, + ); + // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. + // platform-runtime.ts is already static in this module, so the deferred imports keep these thin + // composition modules (and the readiness/open-target adapters only the services module pulls) + // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. + const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); + const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); - return Object.freeze({ - ownedProcessRecords, - hostDiagnostics, - ownerLifecycle: platformDaemonLifecycleOwners, - providerDeviceRuntimes, - skippedProviderRuntimes: Object.freeze([...(providerComposition.skipped ?? [])]), - leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, - providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, - recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, - recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, - deviceRuntimeGateway, - platformResourceCleanup, - claimRecoveryGateway: createClaimRecoveryGateway, - interactorResolution: Object.freeze({ resolve: getInteractor }), - providerDeviceAdmission: Object.freeze({ - isActive: (device: DeviceInfo) => isActiveProviderDevice(device), - }), - cleanupManagedWebOrphans: cleanupWebBrowserOrphansForDaemonStartup, - requestRouter: Object.freeze({ - requestPlatformProviders, - deviceInventoryGateways, - platformServices: createDaemonPlatformServices(), - androidObservation, - providerCredentials: readDaemonProviderCredentials(env, baseDir), - createCommandSurface: async (config: AgentDeviceRuntimeConfig) => - ( - await import('./command-runtime/runtime-command-surface.ts') - ).createCommandSurfaceAgentDevice(config), - cloudArtifactProvider: providerRuntimeProviders.cloudArtifactProvider, - providerAppCatalog: providerRuntimeProviders.providerAppCatalog, - providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, - }), - }); + return Object.freeze({ + ownedProcessRecords, + hostDiagnostics, + ownerLifecycle: platformDaemonLifecycleOwners, + shutdownProviderRuntimes: providerComposition.shutdown, + pluginInventory: providerComposition.inventory, + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, + recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, + recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, + deviceRuntimeGateway, + platformResourceCleanup, + claimRecoveryGateway: createClaimRecoveryGateway, + interactorResolution: Object.freeze({ resolve: getInteractor }), + providerDeviceAdmission: Object.freeze({ + isActive: (device: DeviceInfo) => isActiveProviderDevice(device), + }), + cleanupManagedWebOrphans: cleanupWebBrowserOrphansForDaemonStartup, + requestRouter: Object.freeze({ + requestPlatformProviders, + deviceInventoryGateways, + platformServices: createDaemonPlatformServices(), + androidObservation, + providerCredentials: readDaemonProviderCredentials( + env, + baseDir, + providerComposition.inventory, + ), + createCommandSurface: async (config: AgentDeviceRuntimeConfig) => + ( + await import('./command-runtime/runtime-command-surface.ts') + ).createCommandSurfaceAgentDevice(config), + cloudArtifactProvider: providerRuntimeProviders.cloudArtifactProvider, + providerAppCatalog: providerRuntimeProviders.providerAppCatalog, + providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, + }), + }); + } catch (error) { + await providerComposition.shutdown(); + throw error; + } }; diff --git a/src/daemon-runtime-limrun-public-http.test.ts b/src/daemon-runtime-limrun-public-http.test.ts index a1228e621f..75e41091c2 100644 --- a/src/daemon-runtime-limrun-public-http.test.ts +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -23,7 +23,12 @@ const rootPorts = vi.hoisted(() => { finalizeDaemonShutdown: vi.fn(async () => {}), }; return { - providerComposition: vi.fn(async () => ({ runtimes: [] as unknown[], platformModules: [] })), + providerComposition: vi.fn(async () => ({ + runtimes: [] as unknown[], + platformModules: [] as unknown[], + inventory: [] as unknown[], + shutdown: async () => [], + })), createPlatformRuntimeGateway: vi.fn(() => ({ applicationLifecycle, inspectFacts: async () => { @@ -76,7 +81,15 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov { apiKey: 'lim_test_key', runtimeInstance: 'http-access-test' }, limrunTestDependencies, ); - rootPorts.providerComposition.mockResolvedValue({ runtimes: [limrun], platformModules: [] }); + rootPorts.providerComposition.mockResolvedValue({ + runtimes: [limrun], + platformModules: [], + inventory: [], + shutdown: async () => { + await limrun.shutdown(); + return []; + }, + }); let runtime: Awaited> | undefined; try { diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index 3b093b362b..374d66a4d3 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -2,11 +2,19 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; import { - providerCredentialFingerprint, - readDaemonProviderCredentials, + providerCredentialFingerprint as fingerprintFor, + readDaemonProviderCredentials as daemonCredentialsFor, } from './provider-credential-fingerprint.ts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; import { pluginHome, selectPluginManifest } from '@agent-device/test-kit'; +const inventory = await builtinPluginInventory(); +const providerCredentialFingerprint = (provider: string, env: EnvMap, leaseBackend?: string) => + fingerprintFor(provider, env, leaseBackend, inventory); +const readDaemonProviderCredentials = (env: EnvMap, stateDir: string) => + daemonCredentialsFor(env, stateDir, inventory); + const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; test.for([ @@ -57,7 +65,7 @@ test('a fingerprint hashes the exact values each provider reads', () => { }), ).not.toBe(browserstack); const limrun = providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: 'lim-key' }); - expect(providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: ' lim-key ' })).toBe(limrun); + expect(providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: ' lim-key ' })).not.toBe(limrun); }); test('a provider name that only matches an inherited object key has no fingerprint', () => { diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts index 479c632e51..ae316448eb 100644 --- a/src/provider-credential-fingerprint.ts +++ b/src/provider-credential-fingerprint.ts @@ -1,35 +1,27 @@ import crypto from 'node:crypto'; import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; -import { cloudWebDriverProviderDeclaration } from '@agent-device/provider-webdriver/providers'; -import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; +import { + pluginCredentialVariables, + type PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; import type { EnvMap } from '@agent-device/kernel/source-value'; -import { readLimrunCredentialValues } from './provider-limrun-credentials.ts'; -import { RESERVED_PLUGIN_PROVIDERS } from './provider-plugin-policy.ts'; -import { installedPlugins } from '@agent-device/plugins/store'; +import { legacyStoreManifests } from '@agent-device/plugins/store'; type CredentialValues = Readonly>; -// Limrun's reader narrows the variables to the leased platform; every other provider declares a -// flat variable list, bundled or installed. -const PROVIDER_CREDENTIAL_READERS: ReadonlyMap< - string, - (env: EnvMap, leaseBackend?: string) => CredentialValues -> = new Map([['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues]]); - /** * A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`, * or undefined when `env` holds none of them or the provider's credentials do not come from the - * environment. + * environment. Variable names come from the daemon's plugin inventory, then from v1 manifests. */ export function providerCredentialFingerprint( provider: string, env: EnvMap, leaseBackend?: string, + inventory?: readonly PluginInventoryEntry[], ): string | undefined { - const read = PROVIDER_CREDENTIAL_READERS.get(provider); - if (read) return digest(read(env, leaseBackend)); + const variables = declaredCredentialVariables(provider, env, leaseBackend, inventory); // Whitespace-only counts as unset and other values are kept as is, as the provider's requireEnv does. - const variables = declaredCredentialVariables(provider, env); return variables ? digest( Object.fromEntries( @@ -39,22 +31,28 @@ export function providerCredentialFingerprint( : undefined; } -// Read from the declaration or manifest, so neither the client nor the daemon evaluates provider code. -function declaredCredentialVariables(provider: string, env: EnvMap): readonly string[] | undefined { - if ((RESERVED_PLUGIN_PROVIDERS as readonly string[]).includes(provider)) - return cloudWebDriverProviderDeclaration(provider)?.credentialVariables; - return installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) +// Read from the inventory or manifest, so neither the client nor the daemon evaluates provider code. +function declaredCredentialVariables( + provider: string, + env: EnvMap, + leaseBackend: string | undefined, + inventory: readonly PluginInventoryEntry[] | undefined, +): readonly string[] | undefined { + const published = inventory && pluginCredentialVariables(inventory, provider, leaseBackend); + if (published) return published; + return legacyStoreManifests(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) ?.agentDevicePlugin.credentialVariables; } export function readDaemonProviderCredentials( env: EnvMap, stateDir: string, + inventory: readonly PluginInventoryEntry[], ): DaemonProviderCredentials { const startupEnv = { ...env }; return { fingerprint: (provider, leaseBackend) => - providerCredentialFingerprint(provider, startupEnv, leaseBackend), + providerCredentialFingerprint(provider, startupEnv, leaseBackend, inventory), stateDir, }; } diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 2b26812de7..8553fb68f5 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -42,7 +42,6 @@ export type ProviderDeviceRuntimeRequestProviders = { /** Eager provider ownership metadata for the platform-runtime composition boundary. */ providerRuntimes: readonly ProviderDeviceRuntime[]; providerRuntimeIds: readonly string[]; - providerRuntimeRequiredIds: readonly string[]; recoverableProviderIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; @@ -93,17 +92,12 @@ function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] { export function createProviderDeviceRuntimeRequestProviders( runtimes: ProviderDeviceRuntime[], - options: { providerRuntimeRequiredIds?: readonly string[] } = {}, ): ProviderDeviceRuntimeRequestProviders { assertUniqueProviderRuntimeIds(runtimes); const providerRuntimeIds = runtimes.map((runtime) => runtime.provider); return { providerRuntimes: Object.freeze([...runtimes]), providerRuntimeIds, - providerRuntimeRequiredIds: uniqueProviderIds([ - ...providerRuntimeIds, - ...(options.providerRuntimeRequiredIds ?? []), - ]), leaseLifecycleProvider: composeLeaseProvider(runtimes), recoverableProviderIds: runtimes .filter((runtime) => runtime.recoverExpiredLease !== undefined) @@ -186,10 +180,6 @@ function composeExpiredLeaseRecovery( }; } -function uniqueProviderIds(providerIds: readonly string[]): string[] { - return [...new Set(providerIds)]; -} - function composeLeaseProvider( runtimes: ProviderDeviceRuntime[], ): LeaseLifecycleProvider | undefined { diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index ec9705f5e9..dcad5a81f4 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,147 +1,51 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; import type { - PlatformRuntimeHost, - PlatformRuntimeOwner, - PlatformRuntimeProviderModule, -} from '@agent-device/contracts/platform-runtime-operations'; + PluginCleanupOutcome, + PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gateway.ts'; -import { asAppError, type AppError } from '@agent-device/kernel/errors'; -import { - BUNDLED_CLOUD_WEBDRIVER_PROVIDER_IDS, - bundledCloudWebDriverRuntimes, -} from './provider-webdriver.ts'; -import { readLimrunCredentials, type LimrunCredentials } from './provider-limrun-credentials.ts'; +import { builtinProviderCandidates } from './builtin-provider-plugins.ts'; +import { PROVIDER_PLUGIN_POLICY } from './provider-plugin-policy.ts'; export type DefaultProviderDeviceRuntimeEnv = NodeJS.ProcessEnv; -export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: readonly string[] = Object.freeze([ - ...BUNDLED_CLOUD_WEBDRIVER_PROVIDER_IDS, - 'limrun' satisfies typeof LIMRUN_PROVIDER, -]); - -export type DefaultProviderRuntimeComposition = Readonly<{ +export type ProviderRuntimeComposition = Readonly<{ runtimes: readonly ProviderDeviceRuntime[]; platformModules: readonly PlatformRuntimeProviderRegistration[]; - /** Providers left out because their environment configuration is invalid; the rest still load. */ - skipped?: readonly Readonly<{ provider: string; error: AppError }>[]; + /** Every plugin the registry saw, built-in or not, active or failed. */ + inventory: readonly PluginInventoryEntry[]; + /** Shuts down every registered runtime and runs its plugin's cleanup; the one cleanup owner. */ + shutdown(): Promise; }>; /** - * Extracts eager provider-owner metadata without loading a provider implementation. Both the - * daemon composition and integration harnesses use this one classification so a provider-owned - * device can never silently select a local lifecycle runtime merely because its registration was - * forgotten at a call site. + * The daemon's providers from one registry: built-in providers, then the plugin store. A plugin + * that cannot register is listed in the inventory; the others still register. */ -export function createProviderPlatformRuntimeRegistrations( - runtimes: readonly ProviderDeviceRuntime[], -): readonly PlatformRuntimeProviderRegistration[] { - return Object.freeze( - runtimes.flatMap((runtime) => - hasPlatformRuntimeModule(runtime) - ? [{ runtime, module: providerPlatformRuntimeModule(runtime) }] - : [], - ), - ); -} - export async function createDaemonProviderRuntimeComposition( env: DefaultProviderDeviceRuntimeEnv = process.env, -): Promise { - const bundled = await createDefaultProviderRuntimeComposition(env); - try { - const [{ loadProviderPlugins }, { PROVIDER_PLUGIN_POLICY }] = await Promise.all([ - import('@agent-device/plugins/load'), - import('./provider-plugin-policy.ts'), +): Promise { + // This daemon's sources: the built-in providers, then the plugin store. A host that embeds the + // daemon picks its own candidates; the registry takes whatever candidates it is given. + const [{ resolveProviderPlugins, startProviderRegistry }, { storePluginCandidates }] = + await Promise.all([ + import('@agent-device/plugins/registry'), + import('@agent-device/plugins/store'), ]); - const plugins = await loadProviderPlugins(env, PROVIDER_PLUGIN_POLICY); - return Object.freeze({ - ...bundled, - runtimes: Object.freeze([...bundled.runtimes, ...plugins.map(({ runtime }) => runtime)]), - platformModules: Object.freeze([ - ...bundled.platformModules, - ...plugins.map(({ runtime, platformModule }) => ({ runtime, module: platformModule })), - ]), - }); - } catch (error) { - await Promise.allSettled(bundled.runtimes.map(async (runtime) => await runtime.shutdown())); - throw error; - } -} - -export async function createDefaultProviderRuntimeComposition( - env: DefaultProviderDeviceRuntimeEnv = process.env, -): Promise { - const runtimes = bundledCloudWebDriverRuntimes(env); - const platformModules = [...createProviderPlatformRuntimeRegistrations(runtimes)]; - let limrunCredentials: LimrunCredentials | undefined; - try { - limrunCredentials = readLimrunCredentials(env); - } catch (error) { - return Object.freeze({ - runtimes, - platformModules: Object.freeze(platformModules), - skipped: [{ provider: 'limrun', error: asAppError(error) }], - }); - } - if (!limrunCredentials) { - return Object.freeze({ runtimes, platformModules: Object.freeze(platformModules) }); - } - - const [limrunRuntime, dependencies] = await Promise.all([ - import('@agent-device/provider-limrun'), - import('./sdk/limrun-runtime-dependencies.ts'), + const resolved = await resolveProviderPlugins([ + ...builtinProviderCandidates(env), + ...(await storePluginCandidates(env)), ]); - const registration = limrunRuntime.createLimrunRuntime( - limrunCredentials, - dependencies.createLimrunRuntimeDependencies(), - { includePlatformModule: true }, - ); - return Object.freeze({ - runtimes: Object.freeze([...runtimes, registration.runtime]), - platformModules: Object.freeze([ - ...platformModules, - { runtime: registration.runtime, module: registration.platformModule }, - ]), - }); -} - -type ProviderRuntimeWithPlatformModule = ProviderDeviceRuntime & - Readonly<{ - owner: PlatformRuntimeProviderModule['owner']; - loadRuntime(host: PlatformRuntimeHost): Promise; - }>; - -function hasPlatformRuntimeModule( - runtime: ProviderDeviceRuntime, -): runtime is ProviderRuntimeWithPlatformModule { - if (!('owner' in runtime) || !('loadRuntime' in runtime)) return false; - const owner = runtime.owner; - return ( - typeof runtime.loadRuntime === 'function' && - isProviderRuntimeOwner(owner) && - owner.provider === runtime.provider - ); -} - -function isProviderRuntimeOwner(owner: unknown): owner is PlatformRuntimeProviderModule['owner'] { - return ( - typeof owner === 'object' && - owner !== null && - 'kind' in owner && - owner.kind === 'provider-runtime' && - 'provider' in owner && - typeof owner.provider === 'string' && - 'instance' in owner && - typeof owner.instance === 'string' - ); -} - -function providerPlatformRuntimeModule( - runtime: ProviderRuntimeWithPlatformModule, -): PlatformRuntimeProviderModule { + const registry = await startProviderRegistry(resolved, env, PROVIDER_PLUGIN_POLICY); return Object.freeze({ - owner: runtime.owner, - loadRuntime: async (host) => await runtime.loadRuntime(host), + runtimes: Object.freeze(registry.registrations.map(({ runtime }) => runtime)), + platformModules: Object.freeze( + registry.registrations.map(({ runtime, platformModule }) => ({ + runtime, + module: platformModule, + })), + ), + inventory: registry.inventory, + shutdown: registry.close, }); } diff --git a/src/provider-limrun-credentials.ts b/src/provider-limrun-credentials.ts index 43d0a17efb..bccdc4303c 100644 --- a/src/provider-limrun-credentials.ts +++ b/src/provider-limrun-credentials.ts @@ -42,19 +42,23 @@ export function limrunPlatformForLeaseBackend( } /** - * The value of each credential variable a lease on `leaseBackend` depends on, read by the same - * rule the credential reader uses: the account variables plus that platform's instance variables, - * or every variable when the backend names no platform. + * The variables a Limrun lease reads: the account variables plus the leased platform's instance + * variables, or every variable when the lease backend names no platform. */ -export function readLimrunCredentialValues( - env: EnvMap, - leaseBackend?: string, -): Readonly> { - const platform = limrunPlatformForLeaseBackend(leaseBackend); - const names = platform - ? [...Object.values(ACCOUNT_VARS), ...INSTANCE_VARS[platform]] - : LIMRUN_CREDENTIAL_VARIABLES; - return Object.fromEntries(names.map((name) => [name, readValue(env, name)])); +export function limrunCredentialDeclaration(): Readonly<{ + variables: readonly string[]; + byBackend: Readonly>; +}> { + const account = Object.values(ACCOUNT_VARS); + return { + variables: LIMRUN_CREDENTIAL_VARIABLES, + byBackend: Object.fromEntries( + [...LEASE_BACKEND_PLATFORMS].map(([backend, platform]) => [ + backend, + [...account, ...INSTANCE_VARS[platform]], + ]), + ), + }; } /** The variables that give access to an existing instance of a platform. */ diff --git a/src/provider-plugin-policy.test.ts b/src/provider-plugin-policy.test.ts index 2e8ce91dca..8e0c4f8776 100644 --- a/src/provider-plugin-policy.test.ts +++ b/src/provider-plugin-policy.test.ts @@ -1,32 +1,17 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; +import { afterEach, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceLease } from '@agent-device/contracts/device'; -import { runCmd } from '@agent-device/host-kit/command'; -import { changePlugin, installedPlugins } from '@agent-device/plugins/store'; -import { loadProviderPlugins } from '@agent-device/plugins/load'; import { - pluginHome, - registrationSource, - selectPlugin, - webDriverPluginSource, - writePlugin, -} from '@agent-device/test-kit'; -import { PROVIDER_PLUGIN_POLICY, RESERVED_PLUGIN_PROVIDERS } from './provider-plugin-policy.ts'; - -vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ - ...(await importOriginal()), - runCmd: vi.fn(), -})); + claimProviderIds, + resolveProviderPlugins, + startProviderRegistry, +} from '@agent-device/plugins/registry'; +import { storePluginCandidates } from '@agent-device/plugins/store'; +import { pluginHome, selectPlugin, webDriverPluginSource } from '@agent-device/test-kit'; +import { PROVIDER_PLUGIN_POLICY } from './provider-plugin-policy.ts'; const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; - vi.doUnmock('@agent-device/provider-webdriver/plugin'); -}); - const lease: DeviceLease = { leaseId: 'lease-1', tenantId: 'team-a', @@ -40,14 +25,25 @@ const lease: DeviceLease = { heartbeatAt: 1, }; -test('WebDriver plugins allocate through the shared engine and refuse mismatched providers', async () => { +async function startStoreRegistry(env: NodeJS.ProcessEnv) { + const resolved = await resolveProviderPlugins([ + ...claimProviderIds([]), + ...(await storePluginCandidates(env)), + ]); + return await startProviderRegistry(resolved, env, PROVIDER_PLUGIN_POLICY); +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +test('a WebDriver plugin allocates through the shared engine under the process policy', async () => { const { home, env } = pluginHome(); - const source = webDriverPluginSource('example', ['awsProjectArn']); - selectPlugin(home, 'example', 'example', source); - const [registration] = await loadProviderPlugins(env, PROVIDER_PLUGIN_POLICY); - const runtime = registration!.runtime; + selectPlugin(home, 'example', 'example', webDriverPluginSource('example', ['awsProjectArn'])); + const registry = await startStoreRegistry(env); + const { runtime, platformModule } = registry.registrations[0]!; assert.equal(runtime.provider, 'example'); - assert.equal(registration!.platformModule.owner.provider, 'example'); + assert.equal(platformModule.owner.provider, 'example'); const requests: string[] = []; globalThis.fetch = async (input, init) => { requests.push(`${init?.method ?? 'GET'} ${String(input)}`); @@ -69,120 +65,20 @@ test('WebDriver plugins allocate through the shared engine and refuse mismatched assert.equal(allocated?.sessionId, 'SESSION1'); assert.deepEqual(requests, ['POST https://webdriver.test/wd/hub/session']); } finally { - await runtime.shutdown(); + await registry.close(); } - const other = pluginHome(); - selectPlugin(other.home, 'example', 'wrong', source); - await assert.rejects(loadProviderPlugins(other.env, PROVIDER_PLUGIN_POLICY), { - code: 'INVALID_ARGS', - }); }); -test('a WebDriver plugin supplies connection callbacks and its runtime is released afterwards', async () => { - const shutdowns: string[] = []; - vi.resetModules(); - vi.doMock('@agent-device/provider-webdriver/plugin', async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - createCloudWebDriverRuntime: async ( - ...args: Parameters - ) => { - const runtime = await original.createCloudWebDriverRuntime(...args); - const shutdown = runtime.shutdown.bind(runtime); - runtime.shutdown = async () => { - shutdowns.push(runtime.provider); - await shutdown(); - }; - return runtime; - }, - }; - }); - const { withPluginConnection: withConnection } = await import('@agent-device/plugins/load'); - const { PROVIDER_PLUGIN_POLICY: policy } = await import('./provider-plugin-policy.ts'); +test('a WebDriver descriptor missing what the engine needs is listed as an invalid plugin', async () => { const { home, env } = pluginHome(); selectPlugin( home, 'example', 'example', - webDriverPluginSource( - 'example', - [], - "{ resolve: () => ({ profile: { leaseProvider: 'example', platform: 'android' } }), verify: async () => { throw host.createError('COMMAND_FAILED', 'verification failed'); } }", - ), - ); - const flags = { json: false, help: false, version: false }; - const resolved = await withConnection( - 'example', - env, - policy, - async (connection) => await connection.resolve({ flags, stateDir: home, cwd: home, env }), + "export default () => ({ webDriver: { provider: 'example' } });", ); - assert.equal(resolved.profile.platform, 'android'); - assert.deepEqual(shutdowns, ['example']); - await assert.rejects( - withConnection( - 'example', - env, - policy, - async (connection) => await connection.verify({ flags, env }), - ), - { code: 'COMMAND_FAILED', message: 'verification failed' }, - ); - assert.deepEqual(shutdowns, ['example', 'example']); -}); - -test('native plugins load without evaluating the WebDriver runtime, WebDriver plugins do', async () => { - const evaluations = { count: 0 }; - vi.resetModules(); - vi.doMock('@agent-device/provider-webdriver/plugin', async (importOriginal) => { - evaluations.count += 1; - return await importOriginal(); - }); - const { loadProviderPlugins: load } = await import('@agent-device/plugins/load'); - const { PROVIDER_PLUGIN_POLICY: policy } = await import('./provider-plugin-policy.ts'); - const native = pluginHome(); - selectPlugin(native.home, 'example', 'example', registrationSource('example')); - const [registration] = await load(native.env, policy); - await registration!.runtime.shutdown(); - assert.equal(evaluations.count, 0); - const webDriver = pluginHome(); - selectPlugin(webDriver.home, 'example', 'example', webDriverPluginSource('example')); - const [adapted] = await load(webDriver.env, policy); - await adapted!.runtime.shutdown(); - assert.equal(evaluations.count, 1); -}); - -test('bundled and connect provider ids are refused at load before any plugin evaluates', async () => { - for (const provider of RESERVED_PLUGIN_PROVIDERS) { - const { home, env } = pluginHome(); - const marker = path.join(home, 'evaluated'); - selectPlugin( - home, - 'example', - provider, - `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`, - ); - await assert.rejects(loadProviderPlugins(env, PROVIDER_PLUGIN_POLICY), { - code: 'INVALID_ARGS', - message: `Duplicate provider plugin: ${provider}`, - }); - assert.ok(!fs.existsSync(marker), provider); - } -}); - -test('bundled and connect provider ids are refused at install and leave no selection', async () => { - for (const provider of RESERVED_PLUGIN_PROVIDERS) { - const { env } = pluginHome(); - vi.mocked(runCmd).mockImplementation(async (_command, _argv, options) => { - writePlugin(options!.cwd!, 'other', 1, provider); - return { stdout: '', stderr: '', exitCode: 0 }; - }); - await assert.rejects( - changePlugin('add', 'other', env, PROVIDER_PLUGIN_POLICY.reservedProviders), - { code: 'INVALID_ARGS', message: `Duplicate provider plugin: ${provider}` }, - ); - assert.deepEqual(installedPlugins(env), [], provider); - } + const registry = await startStoreRegistry(env); + assert.deepEqual(registry.registrations, []); + assert.equal(registry.inventory[0]!.state, 'failed'); + assert.equal(registry.inventory[0]!.error?.reason, 'invalid_plugin'); }); diff --git a/src/provider-plugin-policy.ts b/src/provider-plugin-policy.ts index b38e0f6e3d..03602ea9dc 100644 --- a/src/provider-plugin-policy.ts +++ b/src/provider-plugin-policy.ts @@ -1,45 +1,39 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { PluginConnection } from '@agent-device/plugins/connection'; -import type { PluginLoadPolicy } from '@agent-device/plugins/load'; -import type { CloudWebDriverProviderOptions } from '@agent-device/provider-webdriver/plugin'; -import { - CLOUD_WEBDRIVER_PROVIDERS, - type CloudWebDriverKnownProviderName, -} from '@agent-device/provider-webdriver/providers'; - -/** The one list of provider ids plugins cannot claim: connect routes and bundled runtimes. */ -export const RESERVED_PLUGIN_PROVIDERS: readonly ( - | 'cloud' - | 'proxy' - | 'limrun' - | CloudWebDriverKnownProviderName -)[] = ['cloud', 'proxy', ...Object.values(CLOUD_WEBDRIVER_PROVIDERS), 'limrun']; - -type WebDriverFactoryResult = { - webDriver: CloudWebDriverProviderOptions; - connection?: PluginConnection; -}; +import type { PluginRegistryPolicy } from '@agent-device/plugins/registry'; +import type { CloudWebDriverRuntimeOptions } from '@agent-device/provider-webdriver/plugin'; /** - * The policy every plugin load, connection and installation in this process runs under. The - * WebDriver runtime is evaluated only for a plugin that returns `{ webDriver }`. + * The policy every provider registry in this process runs under. A plugin that returns a + * `{ webDriver }` descriptor gets the shared WebDriver engine, loaded only then. */ -export const PROVIDER_PLUGIN_POLICY: PluginLoadPolicy = Object.freeze({ - reservedProviders: RESERVED_PLUGIN_PROVIDERS, - adaptFactoryResult: async (result, { plugin, host }) => { - if (typeof result !== 'object' || result === null || !('webDriver' in result)) return undefined; - const { webDriver, connection } = result as WebDriverFactoryResult; - if (webDriver?.provider !== plugin.provider) { - throw new AppError( - 'INVALID_ARGS', - `WebDriver plugin provider does not match its declaration: ${plugin.name}`, - ); - } +export const PROVIDER_PLUGIN_POLICY: PluginRegistryPolicy = Object.freeze({ + adaptWebDriver: async (descriptor, { clientVersion }) => { + const options = readWebDriverOptions(descriptor); const { createCloudWebDriverRuntime } = await import('@agent-device/provider-webdriver/plugin'); - const runtime = await createCloudWebDriverRuntime({ - ...webDriver, - clientVersion: host.clientVersion, - }); - return { runtime, platformModule: runtime.platformRuntimeModule, connection }; + const runtime = await createCloudWebDriverRuntime({ ...options, clientVersion }); + return { runtime, platformModule: runtime.platformRuntimeModule }; }, }); + +type WebDriverDescriptorOptions = Omit; + +/** Refuses a descriptor that lacks what the engine needs, so a broken plugin fails at startup and not at the first lease. */ +function readWebDriverOptions(descriptor: object): WebDriverDescriptorOptions { + const fields = descriptor as Partial>; + const endpoint = fields.endpoint; + const valid = + typeof fields.provider === 'string' && + (typeof endpoint === 'string' || endpoint instanceof URL) && + (fields.platform === 'android' || fields.platform === 'ios') && + typeof fields.deviceName === 'string' && + typeof fields.profileFields === 'object' && + fields.profileFields !== null; + if (!valid) { + throw new AppError( + 'INVALID_ARGS', + 'A WebDriver plugin must provide provider, endpoint, platform, deviceName and profileFields', + { reason: 'invalid_plugin' }, + ); + } + return descriptor as WebDriverDescriptorOptions; +} diff --git a/src/provider-webdriver.ts b/src/provider-webdriver.ts index 4d7114873d..b2ab2176ed 100644 --- a/src/provider-webdriver.ts +++ b/src/provider-webdriver.ts @@ -1,19 +1,12 @@ import { - BUNDLED_CLOUD_WEBDRIVER_PROVIDERS, bundledCloudWebDriverProvider, - createBundledCloudWebDriverRuntimes, type CloudWebDriverConnection, type CloudWebDriverProviderHost, - type CloudWebDriverRuntime, } from '@agent-device/provider-webdriver'; import { runCmd } from '@agent-device/host-kit/command'; import { readVersion } from '@agent-device/host-kit/version'; import type { EnvMap } from '@agent-device/kernel/source-value'; -/** The provider IDs the daemon composes without a plugin; each is reserved from plugins. */ -export const BUNDLED_CLOUD_WEBDRIVER_PROVIDER_IDS: readonly string[] = - BUNDLED_CLOUD_WEBDRIVER_PROVIDERS.map((bundled) => bundled.declaration.provider); - /** What this process hands a bundled provider: the plugin host shape plus a host-command runner. */ export function createBundledCloudWebDriverHost(env: EnvMap): CloudWebDriverProviderHost { return { @@ -29,10 +22,6 @@ export function createBundledCloudWebDriverHost(env: EnvMap): CloudWebDriverProv }; } -export function bundledCloudWebDriverRuntimes(env: EnvMap): CloudWebDriverRuntime[] { - return createBundledCloudWebDriverRuntimes(createBundledCloudWebDriverHost(env)); -} - /** The `connect` callbacks of a bundled provider, or undefined for any other provider name. */ export function bundledCloudWebDriverConnection( provider: string, diff --git a/src/sdk/plugins.ts b/src/sdk/plugins.ts index e1a376ac0e..15dca39bd1 100644 --- a/src/sdk/plugins.ts +++ b/src/sdk/plugins.ts @@ -1 +1,27 @@ +import type { + AgentDevicePlugin as ContractPlugin, + AgentDevicePluginFactory as ContractPluginFactory, + PluginSetupResult as ContractSetupResult, + PluginWebDriverSetup as ContractWebDriverSetup, +} from '@agent-device/plugins/contract'; +import type { CloudWebDriverProviderOptions } from '@agent-device/provider-webdriver/plugin'; + export type { ProviderPluginHost } from '@agent-device/plugins/host'; +export type { + PluginConnection, + PluginConnectionContext, + PluginConnectionResolution, + PluginCredentials, + PluginPlatformModule, + PluginProviderRuntime, + PluginRuntimeSetup, +} from '@agent-device/plugins/contract'; + +/** A hosted WebDriver service; the host builds the runtime from the descriptor. */ +export type PluginWebDriverSetup = ContractWebDriverSetup; +export type PluginSetupResult = ContractSetupResult; +export type AgentDevicePlugin = ContractPlugin; +export type AgentDevicePluginFactory> = ContractPluginFactory< + Options, + CloudWebDriverProviderOptions +>; diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index d6e917f45a..d1210e8280 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -90,6 +90,52 @@ async function runNodeModuleJson(cwd: string, args: string[], script: string): P return readJson(stdout); } +/** A v2 plugin written against the packed `agent-device/plugins` types, type-checked as a consumer. */ +async function typeCheckInstalledPluginContract(consumerRoot: string): Promise { + const source = path.join(consumerRoot, 'plugin-contract-smoke.ts'); + fs.writeFileSync( + source, + `import type { AgentDevicePluginFactory } from 'agent-device/plugins'; + +type ExampleOptions = { credentials?: { username: string; accessKey: string } }; + +const example: AgentDevicePluginFactory = (options = {}) => ({ + apiVersion: 2, + provider: 'example', + credentials: { + variables: options.credentials ? [] : ['EXAMPLE_USERNAME', 'EXAMPLE_ACCESS_KEY'], + byBackend: { 'android-instance': ['EXAMPLE_USERNAME'] }, + }, + setup: async (host) => ({ + runtime: { provider: 'example', shutdown: async () => {}, hostEnv: host.env }, + platformModule: { + owner: { kind: 'provider-runtime', provider: 'example', instance: 'example' }, + loadRuntime: async () => ({}), + }, + }), +}); + +export default example; +`, + ); + await execFileText( + process.execPath, + [ + path.join(repoRoot, 'node_modules/typescript/bin/tsc'), + '--noEmit', + '--strict', + '--target', + 'es2022', + '--module', + 'nodenext', + '--moduleResolution', + 'nodenext', + source, + ], + { cwd: consumerRoot }, + ); +} + function acceptWebSocket(socket: Duplex, key: string): void { const accept = crypto .createHash('sha1') @@ -227,12 +273,25 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi ); const pluginTypes = fs.readFileSync(path.join(installedPackageRoot, 'dist/src/plugins.d.ts')); - assert.ok(pluginTypes.length < 1024); + assert.ok(pluginTypes.length < 16 * 1024); const webDriverPluginTypes = fs.readFileSync( path.join(installedPackageRoot, 'dist/src/plugins/webdriver.d.ts'), ); assert.ok(webDriverPluginTypes.length < 5120); - assert.match(pluginTypes.toString(), /export (?:type )?\{ ProviderPluginHost \}/); + for (const exported of [ + 'AgentDevicePlugin', + 'AgentDevicePluginFactory', + 'PluginConnection', + 'PluginCredentials', + 'PluginSetupResult', + 'ProviderPluginHost', + ]) { + assert.match( + pluginTypes.toString(), + new RegExp(`export (?:type )?\\{[^}]*\\b${exported}\\b`), + ); + } + await typeCheckInstalledPluginContract(consumerRoot); metroPort = await listenOnLoopback(metroServer); t.after(async () => { await closeLoopbackServer(metroServer); diff --git a/test/integration/provider-scenarios/bundled-cloud-webdriver-runtimes.ts b/test/integration/provider-scenarios/bundled-cloud-webdriver-runtimes.ts new file mode 100644 index 0000000000..08906126cf --- /dev/null +++ b/test/integration/provider-scenarios/bundled-cloud-webdriver-runtimes.ts @@ -0,0 +1,18 @@ +import { + BUNDLED_CLOUD_WEBDRIVER_PROVIDERS, + type CloudWebDriverProviderHost, +} from '@agent-device/provider-webdriver'; +import { createCloudWebDriverRuntime } from '@agent-device/provider-webdriver/plugin'; + +/** One runtime per bundled provider, composed the way the registry composes a WebDriver descriptor. */ +export async function createBundledCloudWebDriverRuntimes(host: CloudWebDriverProviderHost) { + return await Promise.all( + BUNDLED_CLOUD_WEBDRIVER_PROVIDERS.map( + async (bundled) => + await createCloudWebDriverRuntime({ + ...bundled.create(host).webDriver, + clientVersion: host.clientVersion, + }), + ), + ); +} diff --git a/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts b/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts index 31d576e1b0..e303c7443d 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts @@ -1,7 +1,7 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createBundledCloudWebDriverRuntimes } from '@agent-device/provider-webdriver'; +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import type { DeviceLease } from '@agent-device/contracts/device'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { assertRpcOk } from './assertions.ts'; @@ -252,7 +252,7 @@ function textEntryTranscript(server: FakeIosWebDriverServer): string[] { async function createCloudIosWorld() { const server = await FakeIosWebDriverServer.start(); - const runtimes = createBundledCloudWebDriverRuntimes({ + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'browser-user', BROWSERSTACK_ACCESS_KEY: 'browser-key', diff --git a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts index 3212d62642..99aac4b193 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import { test } from 'vitest'; -import { createBundledCloudWebDriverRuntimes } from '@agent-device/provider-webdriver'; +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { buildHttpRpcPayload } from '../../../src/daemon-client/daemon-client-rpc.ts'; import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; @@ -30,7 +30,7 @@ test('lease_allocate over HTTP prepares the BrowserStack session end to end', as } await withProviderScenarioResource(FakeBrowserStackServer.start, async (server) => { - const runtimes = createBundledCloudWebDriverRuntimes({ + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'browser-user', BROWSERSTACK_ACCESS_KEY: 'browser-key', diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index 42b6d0b786..c21b78ca5b 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -1,3 +1,4 @@ +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import type { IncomingHttpHeaders } from 'node:http'; @@ -6,7 +7,6 @@ import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { bundledCloudWebDriverProvider, - createBundledCloudWebDriverRuntimes, type RunHostCommand, } from '@agent-device/provider-webdriver'; import type { @@ -38,7 +38,7 @@ test('BrowserStack facade prepares capabilities, uploads apps, and returns artif const appPath = path.join(tempDir, 'demo.apk'); fs.writeFileSync(appPath, 'fake apk'); const runtime = runtimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key', @@ -91,7 +91,7 @@ test('BrowserStack facade prepares capabilities, uploads apps, and returns artif test('BrowserStack facade nests device-feature capabilities inside bstack:options', async () => { await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const runtime = runtimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key', @@ -153,7 +153,7 @@ test('AWS Device Farm facade rejects device features it does not read at session await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const host = new FakeAwsHostCommand(`${server.url}/wd/hub/`); const runtime = runtimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: CLIENT_VERSION, runHostCommand: host.run, @@ -326,7 +326,7 @@ test('TestMu uploads the materializer-selected simulator archive through the plu test('BrowserStack refuses a refused field on a repeat allocation of its live lease', async () => { await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const runtime = runtimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key', @@ -369,7 +369,7 @@ test('AWS Device Farm facade uses the injected host-command capability for its f await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const host = new FakeAwsHostCommand(`${server.url}/wd/hub/`); const runtime = runtimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: CLIENT_VERSION, runHostCommand: host.run, diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts index 21c87b1cb8..c6666abcf8 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts @@ -1,12 +1,10 @@ +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; -import { - createBundledCloudWebDriverRuntimes, - type RunHostCommand, -} from '@agent-device/provider-webdriver'; +import { type RunHostCommand } from '@agent-device/provider-webdriver'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; @@ -35,7 +33,7 @@ test('AWS Device Farm endpoint selection skips live-control WebSocket URLs', asy appiumEndpoint: `${server.url}/wd/hub/`, }); const runtime = providerRuntimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, runHostCommand: host.run, @@ -65,7 +63,7 @@ test('WebDriver session creation is not retried on transient provider failures', await withProviderScenarioResource(ProviderRegressionServer.start, async (server) => { server.sessionFailuresRemaining = 1; const runtime = providerRuntimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key', @@ -99,7 +97,7 @@ test('AWS Device Farm rejects local artifact install until upload support exists appiumEndpoint: `${server.url}/wd/hub/`, }); const runtime = providerRuntimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, runHostCommand: host.run, @@ -132,7 +130,7 @@ test('AWS Device Farm rejects local artifact install until upload support exists test('an active AWS Device Farm owner binds no install-family deployment operations', async () => { await withProviderScenarioResource(ProviderRegressionServer.start, async (server) => { const host = new AwsRemoteAccessHost({ appiumEndpoint: `${server.url}/wd/hub/` }); - const runtimes = createBundledCloudWebDriverRuntimes({ + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, runHostCommand: host.run, @@ -206,7 +204,7 @@ test('AWS Device Farm sends the requested platform in WebDriver capabilities', a device: { name: 'Apple iPhone 13', platform: 'IOS', os: '16.0.2' }, }); const runtime = providerRuntimeFor( - createBundledCloudWebDriverRuntimes({ + await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, runHostCommand: host.run, @@ -235,7 +233,7 @@ test('BrowserStack iOS snapshot reaches one shared presenter and public daemon o await withProviderScenarioResource( () => ProviderRegressionServer.start('ios'), async (server) => { - const runtimes = createBundledCloudWebDriverRuntimes({ + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key', diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 51be0b2d83..81d0c31189 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; -import { createBundledCloudWebDriverRuntimes } from '@agent-device/provider-webdriver'; +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import type { CloudArtifact } from '@agent-device/contracts/observability'; import type { DeviceLease } from '@agent-device/contracts/device'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; @@ -308,7 +308,7 @@ async function waitForLeaseExpiry(server: FakeWebDriverServer): Promise { async function createCloudWebDriverWorld(params: { leaseTtlMs?: number } = {}) { const server = await FakeWebDriverServer.start(); - const runtimes = createBundledCloudWebDriverRuntimes({ + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'browser-user', BROWSERSTACK_ACCESS_KEY: 'browser-key', diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 187f41b587..fd26d9a11a 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -45,7 +45,7 @@ import { createTestDeviceInventoryGatewaysFromProvider, } from '@agent-device/device-selection/inventory-gateways-fixtures'; import type { PlatformRuntimeProviderRegistration } from '../../../src/platform-runtime-gateway.ts'; -import { createProviderPlatformRuntimeRegistrations } from '../../../src/provider-device-runtimes.ts'; +import { createProviderPlatformRuntimeRegistrations } from './provider-platform-registrations.ts'; const PROVIDER_SCENARIO_TEMP_REMOVE_OPTIONS = { recursive: true, diff --git a/test/integration/provider-scenarios/provider-platform-registrations.ts b/test/integration/provider-scenarios/provider-platform-registrations.ts new file mode 100644 index 0000000000..82b9aadaa2 --- /dev/null +++ b/test/integration/provider-scenarios/provider-platform-registrations.ts @@ -0,0 +1,65 @@ +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { PlatformRuntimeProviderRegistration } from '../../../src/platform-runtime-gateway.ts'; + +/** + * Test-only owner inference for scenario runtimes that are not built through the plugin registry. + * The daemon takes each runtime's platform module from the plugin's `setup` result instead; this + * infers an equivalent module from a fixture runtime that carries `owner` and `loadRuntime`, so a + * harness-built gateway can classify provider-owned devices. Runtimes without them are skipped. + */ +export function createProviderPlatformRuntimeRegistrations( + runtimes: readonly ProviderDeviceRuntime[], +): readonly PlatformRuntimeProviderRegistration[] { + return Object.freeze( + runtimes.flatMap((runtime) => + hasPlatformRuntimeModule(runtime) + ? [{ runtime, module: providerPlatformRuntimeModule(runtime) }] + : [], + ), + ); +} + +type ProviderRuntimeWithPlatformModule = ProviderDeviceRuntime & + Readonly<{ + owner: PlatformRuntimeProviderModule['owner']; + loadRuntime(host: PlatformRuntimeHost): Promise; + }>; + +function hasPlatformRuntimeModule( + runtime: ProviderDeviceRuntime, +): runtime is ProviderRuntimeWithPlatformModule { + if (!('owner' in runtime) || !('loadRuntime' in runtime)) return false; + const owner = runtime.owner; + return ( + typeof runtime.loadRuntime === 'function' && + isProviderRuntimeOwner(owner) && + owner.provider === runtime.provider + ); +} + +function isProviderRuntimeOwner(owner: unknown): owner is PlatformRuntimeProviderModule['owner'] { + return ( + typeof owner === 'object' && + owner !== null && + 'kind' in owner && + owner.kind === 'provider-runtime' && + 'provider' in owner && + typeof owner.provider === 'string' && + 'instance' in owner && + typeof owner.instance === 'string' + ); +} + +function providerPlatformRuntimeModule( + runtime: ProviderRuntimeWithPlatformModule, +): PlatformRuntimeProviderModule { + return Object.freeze({ + owner: runtime.owner, + loadRuntime: async (host) => await runtime.loadRuntime(host), + }); +} diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index 847ba7d369..eadd0edd4e 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { DeviceLease } from '@agent-device/contracts/device'; -import { createBundledCloudWebDriverRuntimes } from '@agent-device/provider-webdriver'; +import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import { createLimrunRuntime, type LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; import { limrunTestDependencies } from '../../../src/platform-runtime-gateway.fixtures.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -65,7 +65,7 @@ vi.mock('@limrun/api/instance-client', () => ({ test('a released WebDriver device remains provider-owned with deployment unavailable', async () => { const server = await startCloudWebDriverTestServer(new StaleWebDriverServer()); - const runtime = webDriverRuntime(server.url); + const runtime = await webDriverRuntime(server.url); const lease = providerLease(CLOUD_WEBDRIVER_PROVIDERS.browserStack, 'webdriver-stale'); try { @@ -137,7 +137,7 @@ test('a released Limrun session exposes unavailable deployment facts before bind test('an active AWS WebDriver device denies unsupported install from facts before bind or provider execution', async () => { const server = await ProviderRegressionServer.start(); const host = new AwsRemoteAccessHost({ appiumEndpoint: `${server.url}/wd/hub/` }); - const runtime = awsWebDriverRuntime(host); + const runtime = await awsWebDriverRuntime(host); const provider = runtime.platformRuntimeModule; if (!provider) throw new Error('AWS WebDriver runtime requires its platform runtime module'); let bindCalls = 0; @@ -229,8 +229,8 @@ test('an active AWS WebDriver device denies unsupported install from facts befor } }); -function webDriverRuntime(endpoint: string) { - const runtimes = createBundledCloudWebDriverRuntimes({ +async function webDriverRuntime(endpoint: string) { + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { BROWSERSTACK_USERNAME: 'browser-user', BROWSERSTACK_ACCESS_KEY: 'browser-key', @@ -248,8 +248,8 @@ function webDriverRuntime(endpoint: string) { return runtime; } -function awsWebDriverRuntime(host: AwsRemoteAccessHost) { - const runtimes = createBundledCloudWebDriverRuntimes({ +async function awsWebDriverRuntime(host: AwsRemoteAccessHost) { + const runtimes = await createBundledCloudWebDriverRuntimes({ env: { AWS_REGION: 'us-west-2' }, clientVersion: 'test', runHostCommand: host.run, @@ -262,7 +262,7 @@ function awsWebDriverRuntime(host: AwsRemoteAccessHost) { } async function allocatedDevice( - runtime: ReturnType, + runtime: Awaited>, lease: DeviceLease, ): Promise { const devices = await runtime.deviceInventoryProvider({ diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index fc0baea7b8..499661d2c1 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -58,7 +58,7 @@ To support `agent-device connect example`, declare `agentDevicePlugin.connection If the provider reads credentials from the environment, list the variables in `agentDevicePlugin.credentialVariables`, for example `["EXAMPLE_USERNAME", "EXAMPLE_ACCESS_KEY"]`. A local daemon keeps the values it started with; when the shell holds different ones, the first command that allocates a lease refuses with reason `provider-credentials-changed` until the daemon is stopped. Core reads this list from the manifest without loading the plugin, and treats a whitespace-only value as unset. -A lease that names a provider this daemon has no runtime for refuses with `UNSUPPORTED_OPERATION` and reason `provider-not-registered`, before any lease is recorded. The error lists the registered providers; restart the daemon with the provider's plugin installed. Leases with no provider, `proxy`, and `cloud` need no provider runtime. +A lease that names a provider this daemon has no runtime for refuses with `UNSUPPORTED_OPERATION` and reason `provider-not-registered`, before any lease is recorded. The error lists the registered providers and, as `failedPlugins`, any plugin that failed to register for that provider (or failed before its provider was known); restart the daemon with the provider's plugin installed. Leases with no provider, `proxy`, and `cloud` need no provider runtime. Return `connection` alongside the runtime or WebDriver options. Its `resolve({ flags, env, cwd, stateDir })` callback validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the manifest. Core supplies connection identity, session defaults, Metro settings, and persists the profile. Its async `verify({ flags, env })` callback returns the provider verification result. These callbacks run without allocating a device and their temporary runtimes are shut down afterwards.