From a168fd58ab55e837313c28e2142286ce19c48b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 2 Oct 2026 20:51:02 +0200 Subject: [PATCH 1/2] feat: add managed provider plugin installation and loading --- packages/command-registry/src/registry.ts | 11 + .../provider-device-runtimes.test.ts | 35 ++- src/cli.ts | 3 + src/cli/commands/__tests__/plugins.test.ts | 60 ++++++ src/cli/commands/plugins.ts | 40 ++++ src/cli/commands/router.ts | 1 + src/commands/schema/cli-config.test.ts | 26 +++ src/commands/schema/cli-config.ts | 17 +- src/commands/schema/cli-help.ts | 2 +- src/commands/schema/command-overrides.ts | 11 + ...mon-runtime-interactor-composition.test.ts | 38 +++- .../daemon-runtime-lifecycle-shutdown.test.ts | 2 +- .../daemon-runtime-metadata-ownership.test.ts | 2 +- .../daemon-runtime-recording-reaper.test.ts | 2 +- .../daemon-runtime-xctest-device-set.test.ts | 2 +- src/daemon/server/daemon-runtime.ts | 10 +- src/plugins/load.test.ts | 64 ++++++ src/plugins/load.ts | 67 ++++++ src/plugins/manifest.test.ts | 35 +++ src/plugins/manifest.ts | 70 ++++++ src/plugins/plugin.fixtures.ts | 60 ++++++ src/plugins/store.test.ts | 152 +++++++++++++ src/plugins/store.ts | 199 ++++++++++++++++++ src/provider-device-runtimes.ts | 20 ++ src/sdk/plugins.ts | 7 + .../installed-package-metro.test.ts | 5 + website/docs/docs/_meta.json | 5 + website/docs/docs/client-api.md | 2 + website/docs/docs/configuration.md | 5 +- website/docs/docs/plugins.md | 34 +++ 30 files changed, 973 insertions(+), 14 deletions(-) create mode 100644 src/cli/commands/__tests__/plugins.test.ts create mode 100644 src/cli/commands/plugins.ts create mode 100644 src/commands/schema/cli-config.test.ts create mode 100644 src/plugins/load.test.ts create mode 100644 src/plugins/load.ts create mode 100644 src/plugins/manifest.test.ts create mode 100644 src/plugins/manifest.ts create mode 100644 src/plugins/plugin.fixtures.ts create mode 100644 src/plugins/store.test.ts create mode 100644 src/plugins/store.ts create mode 100644 src/sdk/plugins.ts create mode 100644 website/docs/docs/plugins.md diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 7957180c08..ec8d024f6f 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1691,6 +1691,17 @@ export const RAW_COMMAND_DESCRIPTORS = [ mcpExposed: false, platformExecution: NO_PLATFORM_EXECUTION, }, + { + name: 'plugins', + deviceClaimPolicy: 'none', + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/plugins.ts'] as const } : {}), + catalog: { group: 'local-cli' }, + recordsSessionAction: false, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + mcpExposed: false, + platformExecution: NO_PLATFORM_EXECUTION, + }, { name: 'connect', deviceClaimPolicy: 'none', diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index d47efd876b..81243d21f6 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -1,6 +1,39 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createDefaultProviderRuntimeComposition } from '../provider-device-runtimes.ts'; +import { + createDefaultProviderRuntimeComposition, + createDaemonProviderRuntimeComposition, +} from '../provider-device-runtimes.ts'; +import { pluginHome, selectPlugin, registrationSource } from '../plugins/plugin.fixtures.ts'; + +test('daemon composition registers installed provider runtimes and their lazy platform modules', async () => { + const { home, env } = pluginHome(); + 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())); +}); + +test('daemon composition rejects builtin provider IDs before evaluating plugins', 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' }); + } +}); test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => { const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ diff --git a/src/cli.ts b/src/cli.ts index 67246cd596..0bdbb3de5c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -100,6 +100,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'connection', 'close', 'daemon', + 'plugins', 'device', 'disconnect', 'metro', @@ -724,6 +725,7 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connect' || options.command === 'connection' || options.command === 'daemon' || + options.command === 'plugins' || options.command === 'proxy' ) { return null; @@ -751,6 +753,7 @@ function shouldResolveRemoteAuth(command: string): boolean { command !== 'auth' && command !== 'connection' && command !== 'daemon' && + command !== 'plugins' && command !== 'device' && command !== 'proxy' ); diff --git a/src/cli/commands/__tests__/plugins.test.ts b/src/cli/commands/__tests__/plugins.test.ts new file mode 100644 index 0000000000..dab8e1b2fd --- /dev/null +++ b/src/cli/commands/__tests__/plugins.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { runCli } from '../../../cli.ts'; +import { pluginHome, selectPlugin } from '../../../plugins/plugin.fixtures.ts'; +import { parseRawArgs, usageForCommand } from '../../parser/args.ts'; + +vi.mock('../../../provider-device-runtimes.ts', () => { + throw new Error('provider runtimes must remain unloaded'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +test('plugins list and remove route through the CLI and emits JSON without daemon access or plugin evaluation', async () => { + const { home } = pluginHome(); + vi.stubEnv('AGENT_DEVICE_HOME', home); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', path.join(home, 'state')); + vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', '1'); + const marker = path.join(home, 'evaluated'); + selectPlugin( + home, + 'example', + 'example', + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`, + ); + const sendToDaemon = vi.fn(async () => { + throw new Error('unexpected daemon access'); + }); + let stdout = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + await runCli(['plugins', 'list', '--json'], { + sendToDaemon, + }); + const result = JSON.parse(stdout); + assert.equal(result.success, true); + assert.deepEqual(result.data.plugins, [ + { name: 'example', version: '1.2.3', provider: 'example', compatible: true }, + ]); + assert.equal(sendToDaemon.mock.calls.length, 0); + stdout = ''; + await runCli(['plugins', 'remove', 'example', '--json'], { sendToDaemon }); + assert.deepEqual(JSON.parse(stdout).data.plugins, []); + assert.ok(!fs.existsSync(marker)); +}); + +test('plugin schema exposes operator help and preserves scoped npm package specs', async () => { + const args = parseRawArgs(['plugins', 'add', '@example/provider@^1.0.0', '--json']); + assert.equal(args.command, 'plugins'); + assert.deepEqual(args.positionals, ['add', '@example/provider@^1.0.0']); + const help = await usageForCommand('plugins'); + assert.ok(help); + assert.match(help, /plugins list\|add/); +}); diff --git a/src/cli/commands/plugins.ts b/src/cli/commands/plugins.ts new file mode 100644 index 0000000000..5987978e8c --- /dev/null +++ b/src/cli/commands/plugins.ts @@ -0,0 +1,40 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { changePlugin, listPlugins } from '../../plugins/store.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags }) => { + const [action = 'list', name, extra] = positionals; + if ( + extra || + !['add', 'update', 'remove', 'list'].includes(action) || + (action === 'list' ? name !== undefined : !name) + ) { + throw new AppError( + 'INVALID_ARGS', + 'Use plugins list, add , update , or remove ', + ); + } + if (action !== 'list' && name) { + const reserved = + action === 'remove' + ? [] + : (await import('../../provider-device-runtimes.ts')).DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS; + await changePlugin(action as 'add' | 'update' | 'remove', name, process.env, reserved); + } + const plugins = listPlugins(process.env); + 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}`, + ), + ...(action !== 'list' + ? ['Restart the local daemon after closing active sessions to use the changed plugin set.'] + : []), + ...(plugins.length === 0 ? ['No provider plugins installed.'] : []), + ].join('\n'), + ); + return true; +}; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 38a7a6afd1..5d618aaf29 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -13,6 +13,7 @@ const dedicatedCliCommandHandlerLoaders = { disconnect: async () => (await import('./connection.ts')).disconnectCommand, connection: async () => (await import('./connection.ts')).connectionCommand, auth: async () => (await import('./auth.ts')).authCommand, + plugins: async () => (await import('./plugins.ts')).pluginsCommand, daemon: async () => (await import('./daemon.ts')).daemonCommand, device: async () => (await import('./device.ts')).deviceCommand, proxy: async () => (await import('./proxy.ts')).proxyCommand, diff --git a/src/commands/schema/cli-config.test.ts b/src/commands/schema/cli-config.test.ts new file mode 100644 index 0000000000..6c1a62c921 --- /dev/null +++ b/src/commands/schema/cli-config.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { pluginHome } from '../../plugins/plugin.fixtures.ts'; +import { resolveConfigBackedFlagDefaults, resolveUserConfigPath } from './cli-config.ts'; + +test('AGENT_DEVICE_HOME relocates user defaults; project and explicit config cannot select plugins', () => { + const { home, env } = pluginHome(); + const cliFlags = { help: false, version: false, json: false }; + fs.writeFileSync( + path.join(home, 'config.json'), + JSON.stringify({ session: 'custom-home', plugins: {} }), + ); + const options = { command: 'snapshot', cwd: home, cliFlags, env }; + assert.equal(resolveConfigBackedFlagDefaults(options).session, 'custom-home'); + fs.writeFileSync(path.join(home, 'agent-device.json'), JSON.stringify({ plugins: {} })); + assert.throws(() => resolveConfigBackedFlagDefaults(options), { code: 'INVALID_ARGS' }); + const explicit = { + ...options, + cliFlags: { ...cliFlags, config: path.join(home, 'config.json') }, + }; + assert.throws(() => resolveConfigBackedFlagDefaults(explicit), { code: 'INVALID_ARGS' }); + const relative = { AGENT_DEVICE_HOME: './relative' }; + assert.throws(() => resolveUserConfigPath(relative), { code: 'INVALID_ARGS' }); +}); diff --git a/src/commands/schema/cli-config.ts b/src/commands/schema/cli-config.ts index 4ae111632e..b4c642ee71 100644 --- a/src/commands/schema/cli-config.ts +++ b/src/commands/schema/cli-config.ts @@ -2,6 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; 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 { mergeDefinedFlags } from './merge-flags.ts'; import { type FlagKey } from '@agent-device/command-registry/flag-types'; import { projectConfigFlagKeys } from '@agent-device/command-registry/flag-registry'; @@ -55,8 +56,13 @@ function resolveConfigPaths( ]; } -function resolveUserConfigPath(env: EnvMap): string { - return path.join(expandUserHomePath('~', { env }), '.agent-device', 'config.json'); +export function resolveUserConfigPath(env: EnvMap): string { + const home = env.AGENT_DEVICE_HOME + ? expandUserHomePath(env.AGENT_DEVICE_HOME, { env }) + : path.join(expandUserHomePath('~', { env }), '.agent-device'); + if (!path.isAbsolute(home)) + throw new AppError('INVALID_ARGS', 'AGENT_DEVICE_HOME must be absolute or ~/...'); + return path.join(home, 'config.json'); } function resolveInputPath(inputPath: string, cwd: string, env: EnvMap): string { @@ -99,7 +105,7 @@ function loadSingleConfigFile(entry: ConfigPath): Partial { }); } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isRecord(parsed)) { throw new AppError('INVALID_ARGS', `Config file must contain a JSON object: ${filePath}`); } @@ -114,7 +120,10 @@ function parseConfigObject( origin: { source: ConfigFileSource; label: string }, ): Partial { const flags: Partial = {}; - for (const [rawKey, rawValue] of Object.entries(source)) { + const entries = Object.entries(source).filter( + ([key]) => key !== 'plugins' || origin.source !== 'user', + ); + for (const [rawKey, rawValue] of entries) { const key = rawKey as FlagKey; const spec = getOptionSpec(key); if (!spec) { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 24957278e4..1c24a8d164 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -28,7 +28,7 @@ import { renderCliHelpOverview } from './cli-help-overview.ts'; import { foldableHelpTopic } from '../system/index.ts'; const CONFIGURATION_LINES = [ - 'Default config files: ~/.agent-device/config.json, ./agent-device.json (project-safe defaults only).', + 'Default config files: ~/.agent-device/config.json (or /config.json), ./agent-device.json (project-safe defaults only).', 'Use --config or AGENT_DEVICE_CONFIG for explicit connection/provider defaults; project config cannot select endpoints or credentials.', ] as const; diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 728c32ffd1..25e68ffbd1 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -10,6 +10,17 @@ import { type SchemaOnlyCliCommandName = Exclude; const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { + plugins: { + text: { + summary: 'Manage installed provider plugins', + description: + 'Install, list, update, or remove npm provider plugins in AGENT_DEVICE_HOME (default ~/.agent-device). Changes take effect after restarting the local daemon. Use --json for structured results.', + }, + usageOverride: 'plugins list|add |update |remove ', + listUsageOverride: 'plugins', + positionalArgs: ['list|add|update|remove', 'package?'], + supportedFlags: [], + }, cdp: { text: { summary: 'Inspect CDP targets, JS heap, and leaks', diff --git a/src/daemon/server/daemon-runtime-interactor-composition.test.ts b/src/daemon/server/daemon-runtime-interactor-composition.test.ts index 5bdce4a34b..e867a0755f 100644 --- a/src/daemon/server/daemon-runtime-interactor-composition.test.ts +++ b/src/daemon/server/daemon-runtime-interactor-composition.test.ts @@ -5,6 +5,7 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; import { setActiveProviderDeviceRuntimes } from '../../provider-device-runtime.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { createDaemonProviderRuntimeComposition } from '../../provider-device-runtimes.ts'; import { interactorResolution } from '../interactor-resolution.ts'; vi.mock('../../platform-runtime.ts', () => ({ @@ -31,13 +32,17 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: vi.fn(async () => ({ + runtimes: [], + platformModules: [], + })), })); import { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { setActiveProviderDeviceRuntimes([]); + vi.restoreAllMocks(); }); /** @@ -78,3 +83,34 @@ test('daemon startup composes the interactor resolution the daemon resolves thro fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +test('a daemon attempt losing the lock shuts down every constructed provider', async () => { + vi.spyOn(await import('./server-lifecycle.ts'), 'acquireDaemonLock').mockReturnValueOnce(false); + const shutdown = vi.fn(() => { + throw new Error('cleanup failed'); + }); + const otherShutdown = vi.fn(async () => {}); + vi.mocked(createDaemonProviderRuntimeComposition).mockResolvedValueOnce({ + runtimes: [shutdown, otherShutdown].map( + (stop, index) => + ({ + provider: `fixture-${index}`, + shutdown: stop, + leaseLifecycle: {}, + }) as unknown as ProviderDeviceRuntime, + ), + platformModules: [], + }); + const exit = vi.fn(); + const runtime = await startDaemonRuntime({ + env: { AGENT_DEVICE_STATE_DIR: mkdtempForTestSync('daemon-held-lock-') }, + exit, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + expect(runtime).toBeNull(); + expect(shutdown).toHaveBeenCalledOnce(); + expect(otherShutdown).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(0); +}); diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index 5863da1a48..1b39bd2745 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -43,7 +43,7 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); import { startDaemonRuntime } from './daemon-runtime.ts'; diff --git a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts index 937cd3a848..3d02a6b4fd 100644 --- a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts +++ b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts @@ -33,7 +33,7 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); // The post-lock, pre-publication step the runtime awaits first. Making it throw lands the runtime in diff --git a/src/daemon/server/daemon-runtime-recording-reaper.test.ts b/src/daemon/server/daemon-runtime-recording-reaper.test.ts index a816b129af..dc8fecc065 100644 --- a/src/daemon/server/daemon-runtime-recording-reaper.test.ts +++ b/src/daemon/server/daemon-runtime-recording-reaper.test.ts @@ -39,7 +39,7 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); import { startDaemonRuntime } from './daemon-runtime.ts'; diff --git a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts index ebfe0091f1..87f71b9cbd 100644 --- a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts +++ b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts @@ -50,7 +50,7 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); import { startDaemonRuntime } from './daemon-runtime.ts'; diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index efc0355124..bf1d9c453c 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -22,7 +22,7 @@ import { } from '../../platform-runtime.ts'; import { createHostDiagnostics } from '../../platform-runtime-host-diagnostics.ts'; import { - createDefaultProviderRuntimeComposition, + createDaemonProviderRuntimeComposition, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, } from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -347,7 +347,7 @@ export async function startDaemonRuntime( const daemonProcessStartTime = daemonIdentity.startTime ?? undefined; const daemonCodeOrigin = resolveDaemonCodeOrigin(); const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerComposition = await createDefaultProviderRuntimeComposition(env); + const providerComposition = await createDaemonProviderRuntimeComposition(env); const providerDeviceRuntimes = [...providerComposition.runtimes]; const deviceRuntimeGateway = createPlatformRuntimeGateway({ assertShutdownAllowed: daemonPolicy @@ -649,6 +649,9 @@ export async function startDaemonRuntime( processStartTime: daemonProcessStartTime, }; if (!acquireDaemonLock(baseDir, lockPath, lockData)) { + await Promise.allSettled( + providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), + ); stderr.write('Daemon lock is held by another process; exiting.\n'); exit(0); return null; @@ -735,6 +738,9 @@ export async function startDaemonRuntime( closeServersBestEffort(servers); stopMetadataLossWatch(); await removeOwnDaemonInfo({ infoPath, logPath, owner: daemonIdentity }); + await Promise.allSettled( + providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), + ); releaseDaemonLock(lockPath); await platformDaemonLifecycleOwners.clearDaemonLockConfiguration(); exit(1); diff --git a/src/plugins/load.test.ts b/src/plugins/load.test.ts new file mode 100644 index 0000000000..64afbc9f44 --- /dev/null +++ b/src/plugins/load.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { loadProviderPlugins } from './load.ts'; +import { pluginHome, selectPlugin, registrationSource } from './plugin.fixtures.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ProviderPluginHost } from '../sdk/plugins.ts'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; + +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 runtime = registration!.runtime as ProviderDeviceRuntime & ProviderPluginHost; + assert.deepEqual(runtime.options, { region: 'eu' }); + assert.ok(runtime.createError('INVALID_ARGS', 'bad profile') instanceof AppError); +}); + +test('duplicate providers and incompatible ABI refuse before executing any plugin', async () => { + const { home, env } = pluginHome(); + const marker = path.join(home, 'evaluated'); + selectPlugin( + home, + 'one', + 'limrun', + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`, + ); + await assert.rejects(loadProviderPlugins(env, ['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' }); + assert.ok(!fs.existsSync(marker)); +}); + +test('startup failure cleans up constructed runtimes, including malformed owners', async () => { + for (const instance of ["'test'", 'undefined', '42', '" "']) { + const { home, env } = pluginHome(); + const first = path.join(home, 'first-shutdown'); + const invalid = path.join(home, 'invalid-shutdown'); + selectPlugin(home, 'one', 'one', registrationSource('one', first)); + const source = registrationSource(instance === "'test'" ? 'wrong' : 'two', invalid).replace( + "instance: 'test'", + `instance: ${instance}`, + ); + selectPlugin(home, 'two', 'two', source); + await assert.rejects(loadProviderPlugins(env, []), { code: 'INVALID_ARGS' }); + assert.ok(fs.existsSync(first)); + assert.ok(fs.existsSync(invalid)); + } +}); + +test('cleanup throwing synchronously preserves the factory failure', 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"); }', + ); + selectPlugin(home, 'one', 'one', source); + selectPlugin(home, 'two', 'two', 'export default () => { throw new Error("factory failed"); };'); + await assert.rejects(loadProviderPlugins(env, []), /factory failed/); + assert.ok(fs.existsSync(marker)); +}); diff --git a/src/plugins/load.ts b/src/plugins/load.ts new file mode 100644 index 0000000000..024b7a9573 --- /dev/null +++ b/src/plugins/load.ts @@ -0,0 +1,67 @@ +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 type { ProviderPluginHost } from '../sdk/plugins.ts'; +import { installedPlugins } from './store.ts'; +import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; + +type ProviderPluginRegistration = Readonly<{ + runtime: ProviderDeviceRuntime; + platformModule: PlatformRuntimeProviderModule; +}>; + +export async function loadProviderPlugins( + env: NodeJS.ProcessEnv, + reservedProviders: readonly string[], +): Promise { + const plugins = installedPlugins(env); + assertUniquePluginProviders(plugins, reservedProviders); + const registrations: ProviderPluginRegistration[] = []; + try { + for (const plugin of plugins) { + 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 registration = await ( + module.default as ( + host: ProviderPluginHost, + ) => ProviderPluginRegistration | Promise + )( + Object.freeze({ + env: Object.freeze({ ...env }), + options: Object.freeze({ ...plugin.selection.options }), + createError: (code, message, details) => new AppError(code, message, details), + }), + ); + if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { + throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); + } + registrations.push(registration); + if ( + registration.runtime.provider !== plugin.agentDevicePlugin.provider || + typeof registration.runtime.ownsDevice !== 'function' || + typeof registration.runtime.getInteractor !== 'function' || + typeof registration.runtime.deviceInventoryProvider !== 'function' || + !registration.runtime.leaseLifecycle || + typeof registration.runtime.leaseLifecycle !== 'object' || + registration.platformModule?.owner?.kind !== 'provider-runtime' || + registration.platformModule.owner.provider !== registration.runtime.provider || + typeof registration.platformModule.owner.instance !== 'string' || + registration.platformModule.owner.instance.trim().length === 0 || + typeof registration.platformModule.loadRuntime !== 'function' + ) { + throw new AppError( + 'INVALID_ARGS', + `Plugin runtime owner does not match its declaration: ${plugin.name}`, + ); + } + } + return registrations; + } catch (error) { + await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); + throw error; + } +} diff --git a/src/plugins/manifest.test.ts b/src/plugins/manifest.test.ts new file mode 100644 index 0000000000..a7763c828a --- /dev/null +++ b/src/plugins/manifest.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { readPluginManifest } from './manifest.ts'; +import { pluginHome, writePlugin } from './plugin.fixtures.ts'; + +test('manifest compatibility is checked without evaluating plugin code', () => { + const { home } = pluginHome(); + const directory = writePlugin(home); + assert.equal(readPluginManifest(directory).agentDevicePlugin.provider, 'example'); + writePlugin(home, '@example/provider', 2); + assert.throws(() => readPluginManifest(directory), { + code: 'INVALID_ARGS', + details: { + reason: 'incompatible_plugin', + apiVersion: 1, + hint: 'Install a plugin release supporting agentDevicePlugin.apiVersion 1.', + }, + }); +}); + +test('manifest refuses traversal and symlink entries outside the installed package', () => { + const { home } = pluginHome(); + const directory = writePlugin(home); + const outside = path.join(home, 'outside.js'); + fs.writeFileSync(outside, 'throw new Error("outside");'); + fs.unlinkSync(path.join(directory, 'plugin.js')); + fs.symlinkSync(outside, path.join(directory, 'plugin.js')); + assert.throws(() => readPluginManifest(directory), { code: 'INVALID_ARGS' }); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); + manifest.agentDevicePlugin.entry = '../../../outside.js'; + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)); + assert.throws(() => readPluginManifest(directory), { code: 'INVALID_ARGS' }); +}); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts new file mode 100644 index 0000000000..44b6d2a1c9 --- /dev/null +++ b/src/plugins/manifest.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; + +const PROVIDER_PLUGIN_API_VERSION = 1; +type PluginManifest = { + name: string; + version: string; + agentDevicePlugin: { apiVersion: number; provider: string; entry: string }; +}; + +export function assertUniquePluginProviders( + plugins: readonly PluginManifest[], + reserved: readonly string[], +): 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); + } +} + +export function readPluginManifest(directory: string): PluginManifest { + let manifest: PluginManifest; + try { + const file = path.join(directory, 'package.json'); + manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as PluginManifest; + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `Cannot read plugin manifest: ${directory}`, + {}, + error as Error, + ); + } + const declaration = manifest?.agentDevicePlugin; + if ( + typeof manifest?.name !== 'string' || + typeof manifest?.version !== 'string' || + !declaration || + declaration.apiVersion !== PROVIDER_PLUGIN_API_VERSION || + typeof declaration.provider !== 'string' || + !/^[a-z][a-z0-9-]*$/.test(declaration.provider) || + typeof declaration.entry !== 'string' || + !declaration.entry.startsWith('./') + ) { + 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}.`, + }); + } + resolvePluginEntry(directory, declaration.entry); + return manifest as PluginManifest; +} + +export function resolvePluginEntry(directory: string, entry: 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()) + return resolved; + } catch {} + throw new AppError('INVALID_ARGS', 'Plugin entry must be a readable file inside its package', { + directory, + entry, + }); +} diff --git a/src/plugins/plugin.fixtures.ts b/src/plugins/plugin.fixtures.ts new file mode 100644 index 0000000000..38b80443f9 --- /dev/null +++ b/src/plugins/plugin.fixtures.ts @@ -0,0 +1,60 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; + +export function pluginHome() { + const home = mkdtempForTestSync('provider-plugins-'); + return { home, env: { AGENT_DEVICE_HOME: home } }; +} + +export function writePlugin( + project: string, + name = '@example/provider', + apiVersion = 1, + provider = 'example', + source = 'throw new Error("evaluated");', +) { + const directory = path.join(project, 'node_modules', name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name, + version: '1.2.3', + type: 'module', + agentDevicePlugin: { apiVersion, provider, entry: './plugin.js' }, + }), + ); + fs.writeFileSync(path.join(directory, 'plugin.js'), source); + return directory; +} + +export function selectPlugin( + home: string, + name: string, + provider: string, + source: string, + apiVersion = 1, +) { + const installation = crypto.randomUUID(); + writePlugin(path.join(home, 'plugins', installation), name, apiVersion, provider, source); + 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' } }; + fs.writeFileSync(configPath, JSON.stringify(config)); +} + +export function registrationSource(provider: string, shutdownFile?: string) { + return `import fs from 'node:fs'; + export default host => ({ + runtime: { provider: ${JSON.stringify(provider)}, + ownsDevice: () => false, getInteractor: () => undefined, + deviceInventoryProvider: async () => [], leaseLifecycle: {}, + shutdown: async () => { ${shutdownFile ? `fs.writeFileSync(${JSON.stringify(shutdownFile)}, 'shutdown');` : ''} }, + options: host.options, env: host.env, createError: host.createError }, + platformModule: { owner: { kind: 'provider-runtime', provider: ${JSON.stringify(provider)}, instance: 'test' }, + loadRuntime: async () => { throw new Error('lazy'); } } + });`; +} diff --git a/src/plugins/store.test.ts b/src/plugins/store.test.ts new file mode 100644 index 0000000000..d10648a187 --- /dev/null +++ b/src/plugins/store.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +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, writePlugin, selectPlugin } from './plugin.fixtures.ts'; + +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')); +} + +function installFixture(name = packageName, apiVersion = 1, provider = 'example') { + vi.mocked(runCmd).mockImplementation(async (command, argv, options) => { + assert.equal(command, 'npm'); + assert.equal(argv[argv.indexOf('--prefix') + 1], options!.cwd); + assert.ok(argv.includes('--global=false')); + assert.ok(argv.includes('--ignore-scripts')); + assert.ok(argv.includes('--workspaces=false')); + assert.ok(argv.includes('--package-lock=true')); + writePlugin(options!.cwd!, name, apiVersion, provider); + return { stdout: '', stderr: '', exitCode: 0 }; + }); +} + +test('add, pinned update and remove preserve flags, options and old installations', async () => { + const { home, env } = pluginHome(); + fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify({ platform: 'android' })); + installFixture(); + await changePlugin('add', `${packageName}@1.2.3`, env); + const [first] = installedPlugins(env); + const config = readPluginConfig(env); + config.plugins![packageName]!.options = { region: 'eu' }; + config.plugins![packageName]!.installation = '../damaged'; + fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify(config)); + await changePlugin('update', packageName, env); + const [second] = installedPlugins(env); + assert.notEqual(first!.directory, second!.directory); + assert.deepEqual(second!.selection.options, { region: 'eu' }); + const project = path.resolve(second!.directory, '../../..'); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(project, 'package.json'), 'utf8')).dependencies, + { [packageName]: '1.2.3' }, + ); + assert.equal(readPluginConfig(env).platform, 'android'); + await changePlugin('remove', packageName, env); + assert.deepEqual(installedPlugins(env), []); + assert.ok(fs.existsSync(first!.directory)); + assert.ok(fs.existsSync(second!.directory)); +}); + +test('failed npm install and incompatible replacement preserve the active selection', async () => { + const { home, env } = pluginHome(); + installFixture(); + await changePlugin('add', packageName, env); + const before = fs.readFileSync(path.join(home, 'config.json'), 'utf8'); + vi.mocked(runCmd).mockRejectedValue(new Error('npm failed')); + await assert.rejects(changePlugin('update', packageName, env), /npm failed/); + assert.equal(fs.readFileSync(path.join(home, 'config.json'), 'utf8'), before); + installFixture(packageName, 2); + await assert.rejects(changePlugin('update', packageName, env), { code: 'INVALID_ARGS' }); + assert.equal(fs.readFileSync(path.join(home, 'config.json'), 'utf8'), before); + assert.equal(fs.readdirSync(path.join(home, 'plugins')).length, 1); +}); + +test('concurrent additions serialize without dropping the other package', async () => { + const { env } = pluginHome(); + vi.mocked(runCmd).mockImplementation(async (_, __, options) => { + const { dependencies } = JSON.parse( + fs.readFileSync(path.join(options!.cwd!, 'package.json'), 'utf8'), + ); + const name = Object.keys(dependencies)[0]!; + writePlugin(options!.cwd!, name, 1, name); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + await Promise.all([changePlugin('add', 'constructor', env), changePlugin('add', 'two', env)]); + assert.deepEqual( + installedPlugins(env) + .map(({ name }) => name) + .sort(), + ['constructor', 'two'], + ); +}); + +test('invalid package sources and damaged selections fail before invoking npm', async () => { + const { home, env } = pluginHome(); + for (const name of [ + '--registry=evil', + '../plugin', + 'plugin@file:../plugin', + 'plugin@npm:other', + 'https://example.com/plugin', + ]) { + await assert.rejects(changePlugin('add', name, env), { code: 'INVALID_ARGS' }); + } + fs.writeFileSync( + 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); + await changePlugin('remove', 'one', env); + assert.deepEqual(listPlugins(env), []); + fs.writeFileSync( + path.join(home, 'config.json'), + JSON.stringify({ plugins: { UPPERCASE: null } }), + ); + await changePlugin('remove', 'UPPERCASE', env); + assert.deepEqual(listPlugins(env), []); + assert.equal(vi.mocked(runCmd).mock.calls.length, 0); +}); + +test('provider collisions refuse activation and preserve existing selections', async () => { + const { env } = pluginHome(); + installFixture(); + await assert.rejects(changePlugin('add', packageName, env, ['example']), { + code: 'INVALID_ARGS', + }); + assert.deepEqual(installedPlugins(env), []); + await changePlugin('add', packageName, env); + installFixture('other'); + await assert.rejects(changePlugin('add', 'other', env), { code: 'INVALID_ARGS' }); + assert.equal(installedPlugins(env).length, 1); +}); + +test('broken siblings do not block pinned updates or unrelated additions', async () => { + for (const damage of ['missing', 'json', 'abi']) { + const { home, env } = pluginHome(); + installFixture(); + await changePlugin('add', `${packageName}@1.2.3`, env); + selectPlugin(home, 'broken', 'broken', 'throw new Error("must not evaluate");', 2); + const config = readPluginConfig(env); + config.plugins![packageName]!.options = { region: 'eu' }; + fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify(config)); + const directory = path.join(home, 'plugins', config.plugins!.broken!.installation); + if (damage === 'missing') fs.rmSync(directory, { recursive: true }); + if (damage === 'json') + fs.writeFileSync(path.join(directory, 'node_modules/broken/package.json'), '{'); + await changePlugin('update', packageName, env); + const repaired = readPluginConfig(env).plugins![packageName]!; + assert.deepEqual(repaired.options, { region: 'eu' }); + assert.equal(repaired.version, '1.2.3'); + 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' }); + } +}); diff --git a/src/plugins/store.ts b/src/plugins/store.ts new file mode 100644 index 0000000000..4435a624cb --- /dev/null +++ b/src/plugins/store.ts @@ -0,0 +1,199 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import { runCmd } from '@agent-device/host-kit/command'; +import { acquireProcessLock, publishFileSync } from '@agent-device/host-kit/file'; +import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { resolveUserConfigPath } from '../commands/schema/cli-config.ts'; +import { readPluginManifest, assertUniquePluginProviders } from './manifest.ts'; + +type PluginSelection = { + installation: string; + version?: string; + options?: Record; +}; +type PluginConfig = Record & { plugins?: Record }; +function readPluginConfig(env: NodeJS.ProcessEnv): PluginConfig { + const configPath = resolveUserConfigPath(env); + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as PluginConfig; + if (!isRecord(config) || (config.plugins !== undefined && !isRecord(config.plugins))) + throw new AppError('INVALID_ARGS', 'Plugin config must contain an object'); + return config; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + if (error instanceof SyntaxError) + throw new AppError('INVALID_ARGS', `Invalid plugin JSON: ${configPath}`, {}, error); + throw error; + } +} + +function validatePluginSelection(name: string, selection: PluginSelection): void { + if ( + !isPackageName(name) || + !isRecord(selection) || + typeof selection.installation !== 'string' || + !/^[a-f0-9-]{36}$/.test(selection.installation) || + (selection.version !== undefined && !isVersionSelector(selection.version)) || + (selection.options !== undefined && !isRecord(selection.options)) + ) { + throw new AppError('INVALID_ARGS', `Invalid plugin selection: ${name}`); + } +} + +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), + ); +} + +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) + throw new AppError('INVALID_ARGS', `Plugin package identity mismatch: ${name}`); + return { ...manifest, directory, selection }; +} + +export function listPlugins(env: NodeJS.ProcessEnv) { + const home = path.dirname(resolveUserConfigPath(env)); + return Object.entries(readPluginConfig(env).plugins ?? {}).map(([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) }; + } + }); +} + +type PluginAction = 'add' | 'update' | 'remove'; + +function parsePluginRequest(action: PluginAction, input: string) { + if (action === 'remove') return { name: input, version: undefined }; + const match = + /^(@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)(?:@(.+))?$/.exec(input); + if (!match || !match[1] || (match[2] !== undefined && !isVersionSelector(match[2]))) { + throw new AppError( + 'INVALID_ARGS', + 'Expected an npm package name, optionally followed by @version or @tag', + ); + } + if (action === 'update' && match[2] !== undefined) + throw new AppError('INVALID_ARGS', 'update accepts a package name without a version'); + return { name: match[1], version: match[2] }; +} + +async function stagePlugin( + home: string, + name: string, + version: string | undefined, + env: NodeJS.ProcessEnv, +) { + const installation = crypto.randomUUID(); + const project = path.join(home, 'plugins', installation); + fs.mkdirSync(project, { recursive: true, mode: 0o700 }); + try { + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ private: true, dependencies: { [name]: version ?? 'latest' } }), + ); + await runCmd( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--global=false', + '--prefix', + project, + '--workspaces=false', + '--package-lock=true', + ], + { 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 }; + } catch (error) { + fs.rmSync(project, { recursive: true, force: true }); + throw error; + } +} + +export async function changePlugin( + action: PluginAction, + input: string, + env: NodeJS.ProcessEnv = process.env, + reservedProviders: readonly string[] = [], +): Promise { + const { name, version } = parsePluginRequest(action, input); + const configPath = resolveUserConfigPath(env); + const home = path.dirname(configPath); + fs.mkdirSync(home, { recursive: true, mode: 0o700 }); + const release = await acquireProcessLock({ + lockDirPath: path.join(home, 'plugins.lock'), + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + description: 'plugin installation', + }); + let staged: Awaited> | undefined; + try { + const config = readPluginConfig(env); + const selections = { ...config.plugins }; + const previous = Object.hasOwn(selections, name) ? selections[name] : undefined; + if (action !== 'add' && !Object.hasOwn(selections, name)) + throw new AppError('INVALID_ARGS', `Plugin is not installed: ${name}`); + if (action === 'remove') delete selections[name]; + else { + 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); + selections[name] = { + installation: staged.installation, + version: requested, + ...(isRecord(previous?.options) ? { options: previous.options } : {}), + }; + } + publishFileSync({ + destination: configPath, + contents: `${JSON.stringify({ ...config, plugins: selections }, null, 2)}\n`, + mode: 0o600, + }); + } catch (error) { + if (staged) fs.rmSync(staged.project, { recursive: true, force: true }); + throw error; + } finally { + await release(); + } +} + +function isPackageName(value: string): boolean { + return /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value); +} + +function isVersionSelector(value: unknown): value is string { + return ( + typeof value === 'string' && /^[a-zA-Z0-9*^~<>=| .+-]+$/.test(value) && value.trim().length > 0 + ); +} diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 310da7e70c..9626e395cc 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -40,6 +40,26 @@ export function createProviderPlatformRuntimeRegistrations( ); } +export async function createDaemonProviderRuntimeComposition( + env: DefaultProviderDeviceRuntimeEnv = process.env, +): Promise { + const bundled = await createDefaultProviderRuntimeComposition(env); + try { + const { loadProviderPlugins } = await import('./plugins/load.ts'); + const plugins = await loadProviderPlugins(env, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS); + return Object.freeze({ + 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 { diff --git a/src/sdk/plugins.ts b/src/sdk/plugins.ts new file mode 100644 index 0000000000..188481315e --- /dev/null +++ b/src/sdk/plugins.ts @@ -0,0 +1,7 @@ +import type { AppError, AppErrorCode, AppErrorDetails } from '@agent-device/kernel/errors'; + +export type ProviderPluginHost = Readonly<{ + env: Readonly>; + options: Readonly>; + createError(code: AppErrorCode, message: string, details?: AppErrorDetails): AppError; +}>; diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index ce663509f0..afe2aa0bb1 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -226,6 +226,9 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi false, ); + const pluginTypes = fs.readFileSync(path.join(installedPackageRoot, 'dist/src/plugins.d.ts')); + assert.ok(pluginTypes.length < 1024); + assert.match(pluginTypes.toString(), /export \{ ProviderPluginHost \}/); metroPort = await listenOnLoopback(metroServer); t.after(async () => { await closeLoopbackServer(metroServer); @@ -323,6 +326,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi return runtime.provider; }, './metro': (mod) => mod.buildBundleUrl('https://public.example.test', 'ios'), + './plugins': (mod) => Object.keys(mod).length === 0, './remote-config': (mod) => typeof mod, './selectors': (mod) => mod.isSelectorToken('||') && typeof mod.parseSelectorChain === 'function', @@ -397,6 +401,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './install-source': 'boolean', './io': 'function', './limrun': 'limrun', + './plugins': true, // Type-only subpath: resolving the module from the packed exports map is // the entire runtime check. './remote-config': 'object', diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index 68655a6001..a951d4384a 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -14,6 +14,11 @@ "label": "Installation", "link": "/docs/installation" }, + { + "type": "custom-link", + "label": "Provider Plugins", + "link": "/docs/plugins" + }, { "type": "custom-link", "label": "AI Agent Setup", diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 48e47386d6..43ebf0c90a 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -97,6 +97,8 @@ Supported public entry points for Node consumers: - `runtime.getDeviceSession(device)` - types: `LimrunRuntimeOptions`, `LimrunDeviceSession`, `LimrunAndroidDeviceSession`, `LimrunIosDeviceSession`, `LimrunIosCommandExecution` +- `agent-device/plugins` + - experimental factory context: `ProviderPluginHost`; see [provider plugins](./plugins.md). - `agent-device/ai-sdk` - `createAgentDeviceTools(options)` - types: `AgentDeviceToolSet`, `AgentDeviceTools`, `CreateAgentDeviceToolsOptions` diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 31899d0ebd..ad30d67d35 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -22,6 +22,9 @@ Project-level values override user-level values where they are permitted. Enviro both. CLI flags always win. `--config ` or `AGENT_DEVICE_CONFIG` loads one explicit, operator-controlled file instead of the default locations. +Set `AGENT_DEVICE_HOME` to an absolute path (or `~/...`) to relocate the user config and +[managed provider plugins](./plugins.md). This setting does not relocate daemon state. + `./agent-device.json` cannot contain endpoint, credential, daemon transport/server, tenant/run/lease, provider/cloud, Metro connection, or other operator-controlled fields. The CLI rejects those keys during parse, before it creates a daemon transport or sends a health request. This prevents a repository from @@ -122,7 +125,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT | Category | Env vars | Decision | | --- | --- | --- | -| CLI defaults and config | `AGENT_DEVICE_CONFIG`, `AGENT_DEVICE_SESSION`, `AGENT_DEVICE_PLATFORM`, `AGENT_DEVICE_SCREENSHOT_SCALE`, `AGENT_DEVICE_SESSION_LOCK`, `AGENT_DEVICE_DAEMON_BASE_URL`, `AGENT_DEVICE_DAEMON_AUTH_TOKEN`, `AGENT_DEVICE_CLOUD_BASE_URL` | Public | +| CLI defaults and config | `AGENT_DEVICE_HOME`, `AGENT_DEVICE_CONFIG`, `AGENT_DEVICE_SESSION`, `AGENT_DEVICE_PLATFORM`, `AGENT_DEVICE_SCREENSHOT_SCALE`, `AGENT_DEVICE_SESSION_LOCK`, `AGENT_DEVICE_DAEMON_BASE_URL`, `AGENT_DEVICE_DAEMON_AUTH_TOKEN`, `AGENT_DEVICE_CLOUD_BASE_URL` | Public | | Device scoping | `AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST` | Public | | Local daemon storage | `AGENT_DEVICE_STATE_DIR` | Public | | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public | diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md new file mode 100644 index 0000000000..a668163fa2 --- /dev/null +++ b/website/docs/docs/plugins.md @@ -0,0 +1,34 @@ +# Provider plugins + +Install optional providers with npm available on your host: + +```bash +agent-device plugins add @example/agent-device-provider +agent-device plugins list --json +agent-device plugins update @example/agent-device-provider +agent-device plugins remove @example/agent-device-provider +``` + +Use a full npm package name, optionally with `@version` or `@tag`. `update` preserves valid constraints and options, including when repairing a damaged selection. Run `add @` to change it, or `add ` without a suffix to remove it. + +Each installation has an npm project and lockfile under `AGENT_DEVICE_HOME` (default `~/.agent-device`), selected in its `config.json`. Other settings are preserved. Project config and `--config` cannot select plugins. This home is independent of `--state-dir` and `AGENT_DEVICE_STATE_DIR`. + +Installs disable lifecycle scripts: packages must contain ready-to-run JavaScript and assets. Use trusted packages; factories receive the daemon's host permissions and environment. + +Failed installs keep the previous selection. Existing daemons retain their plugin set and files, including removed installations. Close sessions and run `agent-device daemon stop` with the appropriate `--state-dir`; the next device command uses the new set. Use separate state directories for different plugin homes. + +Compatibility checks run offline using local metadata; provider operations may require network access. An incompatible plugin refuses daemon startup. Run `plugins list --json` to inspect errors, then update or remove the affected package. Core upgrades do not download replacements automatically. + +## Creating a plugin + +The interface is experimental. Publish this declaration in your package manifest: + +```json +{"agentDevicePlugin": {"apiVersion": 1, "provider": "example", "entry": "./dist/plugin.mjs"}} +``` + +Use `agent-device` as a development dependency. The default factory accepts `ProviderPluginHost` from `agent-device/plugins`: `env`, package-specific `options`, and `createError` for host-recognized errors. Return `{ runtime, platformModule }` implementing the [provider runtime](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/provider-device-runtime.ts) and [platform module](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/platform-runtime-operations.ts) contracts. Both provider IDs must match the manifest; the module owner must declare `kind: 'provider-runtime'` and a nonempty `instance`. Core checks required runtime methods and owner metadata at startup; operation contracts are checked when used. Provider packages own implementation types; the SDK exposes only factory context. Set options through `plugins[""].options` in user config; leave `installation` unchanged. + +Keep initialization prompt and free of network I/O or device allocation; a stalled factory blocks startup. Load platform mechanics through `platformModule.loadRuntime` and perform remote work in request-bound operations. A failing factory cleans up its own resources; core shuts down previously returned runtimes if another plugin fails. + +Incompatible contract changes require a new API version. Plugins cannot replace bundled providers or register arbitrary commands. Installing a package does not add `connect `; provider-specific connect adapters need separate support. Limrun, BrowserStack, and AWS Device Farm remain bundled. From 986b32b54190637d16d32a25ae652f0d0ce61007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 12:51:07 +0200 Subject: [PATCH 2/2] chore(gates): declare provider plugin SDK and module ownership --- .fallowrc.json | 4 +++- package.json | 4 ++++ .../src/__tests__/device-claim-policy.test.ts | 1 + scripts/layering/daemon-platform-runtime-inventory.ts | 2 +- scripts/layering/model.ts | 1 + tsdown.config.ts | 1 + 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 659ad8259f..635559ff05 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -9,6 +9,7 @@ "src/sdk/metro.ts", "src/sdk/remote-config.ts", "src/sdk/install-source.ts", + "src/sdk/plugins.ts", "src/sdk/android-adb.ts", "src/sdk/contracts.ts", "src/sdk/selectors.ts", @@ -217,9 +218,10 @@ }, { "comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.", - "file": "src/cli/commands/{auth,connection,daemon,device,proxy,recording,replay,screenshot,takeover}.ts", + "file": "src/cli/commands/{auth,connection,daemon,device,plugins,proxy,recording,replay,screenshot,takeover}.ts", "exports": [ "authCommand", + "pluginsCommand", "connectCommand", "disconnectCommand", "connectionCommand", diff --git a/package.json b/package.json index f61eb2c1bf..396323377e 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,10 @@ "./ai-sdk": { "types": "./dist/src/ai-sdk.d.ts", "import": "./dist/src/ai-sdk.js" + }, + "./plugins": { + "types": "./dist/src/plugins.d.ts", + "import": "./dist/src/plugins.js" } }, "engines": { diff --git a/packages/command-registry/src/__tests__/device-claim-policy.test.ts b/packages/command-registry/src/__tests__/device-claim-policy.test.ts index 97a40dc68f..82b8b503af 100644 --- a/packages/command-registry/src/__tests__/device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/device-claim-policy.test.ts @@ -68,6 +68,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'lease_release', 'mcp', 'metro', + 'plugins', 'proxy', 'react-devtools', 'release_materialized_paths', diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index e50087480a..2704c2573b 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -223,7 +223,7 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] { file: 'src/daemon/server/daemon-runtime.ts', target: 'src/provider-device-runtimes.ts', - symbols: ['createDefaultProviderRuntimeComposition', 'DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS'], + symbols: ['createDaemonProviderRuntimeComposition', 'DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS'], classification: 'composition-essential', rationale: 'process-root assembly of the default provider runtime composition and the ids whose runtime ' + diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 741c9188ca..17f5590ad1 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -64,6 +64,7 @@ const TARGET_DAG_RANK = new Map([ ['metro', 4], ['remote', 4], ['sdk', 4], + ['plugins', 4], ['daemon-client', 5], ['cli', 6], ]); diff --git a/tsdown.config.ts b/tsdown.config.ts index d3b4ea27a3..7131e46e79 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ 'install-source': 'src/sdk/install-source.ts', 'android-adb': 'src/sdk/android-adb.ts', limrun: 'src/sdk/limrun.ts', + plugins: 'src/sdk/plugins.ts', contracts: 'src/sdk/contracts.ts', selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts',