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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"dependencies": {
"@agent-device/contracts": "workspace:*",
"@agent-device/host-kit": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*"
"@agent-device/kernel": "workspace:*"
},
"exports": {
"./connection": {
Expand All @@ -23,10 +22,6 @@
"types": "./src/load.ts",
"default": "./src/load.ts"
},
"./manifest": {
"types": "./src/manifest.ts",
"default": "./src/manifest.ts"
},
"./store": {
"types": "./src/store.ts",
"default": "./src/store.ts"
Expand Down
120 changes: 61 additions & 59 deletions packages/plugins/src/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,26 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { loadProviderPlugins, withPluginConnection } from './load.ts';
import {
pluginHome,
registrationSource,
selectPlugin,
webDriverPluginSource,
} from '@agent-device/test-kit';
loadProviderPlugins,
withPluginConnection,
type PluginLoadPolicy,
type ProviderPluginRegistration,
} from './load.ts';
import { pluginHome, registrationSource, selectPlugin } from '@agent-device/test-kit';
import { AppError } from '@agent-device/kernel/errors';
import type { ProviderPluginHost } from './host.ts';
import type { DeviceLease, ProviderDeviceRuntime } from '@agent-device/contracts/device';
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';

const realFetch = globalThis.fetch;
const lease: DeviceLease = {
leaseId: 'lease-1',
tenantId: 'team-a',
runId: 'run-a',
clientId: 'client-a',
leaseProvider: 'example',
backend: 'android-instance',
deviceKey: 'example:device-a',
createdAt: 1,
expiresAt: 2,
heartbeatAt: 1,
};
const policy = (reservedProviders: readonly string[] = []): PluginLoadPolicy => ({
reservedProviders,
adaptFactoryResult: () => undefined,
});

test('startup loads the factory with options and the host error constructor', async () => {
const { home, env } = pluginHome();
selectPlugin(home, 'example', 'example', registrationSource('example'));
const [registration] = await loadProviderPlugins(env, ['limrun']);
const [registration] = await loadProviderPlugins(env, policy(['limrun']));
const runtime = registration!.runtime as ProviderDeviceRuntime & ProviderPluginHost;
assert.deepEqual(runtime.options, { region: 'eu' });
assert.ok(runtime.createError('INVALID_ARGS', 'bad profile') instanceof AppError);
Expand All @@ -45,10 +36,10 @@ test('duplicate providers and incompatible ABI refuse before executing any plugi
'limrun',
`import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`,
);
await assert.rejects(loadProviderPlugins(env, ['limrun']), { code: 'INVALID_ARGS' });
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, []), { code: 'INVALID_ARGS' });
await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' });
assert.ok(!fs.existsSync(marker));
});

Expand All @@ -63,7 +54,7 @@ test('startup failure cleans up constructed runtimes, including malformed owners
`instance: ${instance}`,
);
selectPlugin(home, 'two', 'two', source);
await assert.rejects(loadProviderPlugins(env, []), { code: 'INVALID_ARGS' });
await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' });
assert.ok(fs.existsSync(first));
assert.ok(fs.existsSync(invalid));
}
Expand All @@ -78,7 +69,7 @@ test('cleanup throwing synchronously preserves the factory failure', async () =>
);
selectPlugin(home, 'one', 'one', source);
selectPlugin(home, 'two', 'two', 'export default () => { throw new Error("factory failed"); };');
await assert.rejects(loadProviderPlugins(env, []), /factory failed/);
await assert.rejects(loadProviderPlugins(env, policy()), /factory failed/);
assert.ok(fs.existsSync(marker));
});

Expand All @@ -94,6 +85,7 @@ test('connection callbacks run from the installed plugin and always release runt
const profile = await withPluginConnection(
'example',
env,
policy(),
async (connection) =>
await connection.resolve({
flags: { json: false, help: false, version: false },
Expand All @@ -109,6 +101,7 @@ test('connection callbacks run from the installed plugin and always release runt
withPluginConnection(
'example',
env,
policy(),
async (connection) =>
await connection.verify({ flags: { json: false, help: false, version: false }, env }),
),
Expand All @@ -117,47 +110,56 @@ test('connection callbacks run from the installed plugin and always release runt
assert.ok(fs.existsSync(marker));
});

test('WebDriver plugins allocate through the shared engine and refuse mismatched providers', async () => {
test('the supplied policy adapts a foreign factory result and its failure releases earlier runtimes', async () => {
const { home, env } = pluginHome();
const source = webDriverPluginSource('example', ['awsProjectArn']);
selectPlugin(home, 'example', 'example', source);
const [registration] = await loadProviderPlugins(env, []);
const runtime = registration!.runtime;
assert.equal(runtime.provider, 'example');
assert.equal(registration!.platformModule.owner.provider, 'example');
const requests: string[] = [];
globalThis.fetch = async (input, init) => {
requests.push(`${init?.method ?? 'GET'} ${String(input)}`);
return new Response(JSON.stringify({ value: { sessionId: 'SESSION1', capabilities: {} } }));
};
try {
await assert.rejects(
runtime.leaseLifecycle.allocate!(lease, { flags: { awsProjectArn: 'arn:project' } }),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.details?.provider, 'example');
assert.deepEqual(error.details?.flags, ['--aws-project-arn']);
return true;
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 () => {},
},
);
assert.deepEqual(requests, []);
const allocated = await runtime.leaseLifecycle.allocate!(lease, { flags: {} });
assert.equal(allocated?.sessionId, 'SESSION1');
assert.deepEqual(requests, ['POST https://webdriver.test/wd/hub/session']);
} finally {
await runtime.shutdown();
globalThis.fetch = realFetch;
}
const other = pluginHome();
selectPlugin(other.home, 'example', 'wrong', source);
await assert.rejects(loadProviderPlugins(other.env, []), { code: 'INVALID_ARGS' });
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'));
assert.deepEqual(
registrations.map(({ runtime }) => runtime.provider),
['one', 'adapted'],
);
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));
});

test('a factory returning a primitive is refused 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, []), { code: 'INVALID_ARGS' });
await assert.rejects(loadProviderPlugins(env, policy()), { code: 'INVALID_ARGS' });
}
});
79 changes: 32 additions & 47 deletions packages/plugins/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,47 @@ 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,
RESERVED_PLUGIN_PROVIDERS,
} from './manifest.ts';
import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts';
import { createPluginHost, type ProviderPluginHost } from './host.ts';
import type { PluginConnection } from './connection.ts';
import type { CloudWebDriverProviderOptions } from '@agent-device/provider-webdriver/plugin';

type ProviderPluginRegistration = Readonly<{
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<ProviderPluginRegistration | undefined>;
}>;

export async function loadProviderPlugins(
env: NodeJS.ProcessEnv,
reservedProviders: readonly string[],
policy: PluginLoadPolicy,
onlyProvider?: string,
): Promise<ProviderPluginRegistration[]> {
const plugins = installedPlugins(env);
assertUniquePluginProviders(plugins, reservedProviders);
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);
const registration = await instantiateProviderPlugin(plugin, env, policy);
registrations.push(registration);
validateProviderPlugin(registration, plugin);
}
Expand All @@ -44,9 +57,10 @@ export async function loadProviderPlugins(
export async function withPluginConnection<T>(
provider: string,
env: NodeJS.ProcessEnv,
policy: PluginLoadPolicy,
runConnection: (connection: PluginConnection) => Promise<T>,
): Promise<T> {
const registrations = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS, provider);
const registrations = await loadProviderPlugins(env, policy, provider);
try {
const connection = registrations.find(
(entry) => entry.runtime.provider === provider,
Expand All @@ -62,55 +76,26 @@ export async function withPluginConnection<T>(
async function instantiateProviderPlugin(
plugin: ReturnType<typeof installedPlugins>[number],
env: NodeJS.ProcessEnv,
policy: PluginLoadPolicy,
): Promise<ProviderPluginRegistration> {
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 = await (
module.default as (
host: ProviderPluginHost,
) =>
| ProviderPluginRegistration
| { webDriver: CloudWebDriverProviderOptions; connection?: PluginConnection }
| Promise<
| ProviderPluginRegistration
| { webDriver: CloudWebDriverProviderOptions; connection?: PluginConnection }
>
)(host);
let registration: ProviderPluginRegistration;
if (isWebDriverPluginResult(result)) {
if (result.webDriver?.provider !== plugin.agentDevicePlugin.provider) {
throw new AppError(
'INVALID_ARGS',
`WebDriver plugin provider does not match its declaration: ${plugin.name}`,
);
}
const { createCloudWebDriverRuntime } = await import('@agent-device/provider-webdriver/plugin');
const runtime = await createCloudWebDriverRuntime({
...result.webDriver,
clientVersion: host.clientVersion,
});
registration = {
runtime,
platformModule: runtime.platformRuntimeModule,
connection: result.connection,
};
} else registration = result;
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 isWebDriverPluginResult(
result: unknown,
): result is { webDriver: CloudWebDriverProviderOptions; connection?: PluginConnection } {
return typeof result === 'object' && result !== null && 'webDriver' in result;
}

function validateProviderPlugin(
registration: ProviderPluginRegistration,
plugin: ReturnType<typeof installedPlugins>[number],
Expand Down
13 changes: 0 additions & 13 deletions packages/plugins/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,6 @@ import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote';

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'];

const PROVIDER_PLUGIN_API_VERSION = 1;
type PluginManifest = {
name: string;
Expand Down
Loading
Loading