Skip to content

Commit fa82eb7

Browse files
thymikeeclaude
andcommitted
feat(plugins): plugin contract v2 and one provider registry
Built-in providers, the plugin store and an embedder now register through one registry in one shape. A plugin that fails to load, validate or set up is recorded in a daemon inventory and no longer stops the daemon. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 4c1c786 commit fa82eb7

59 files changed

Lines changed: 2315 additions & 674 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/contracts/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@
300300
"types": "./src/lease-scope.ts",
301301
"default": "./src/lease-scope.ts"
302302
},
303+
"./plugin-inventory": {
304+
"types": "./src/plugin-inventory.ts",
305+
"default": "./src/plugin-inventory.ts"
306+
},
303307
"./local-interactor-operation-set": {
304308
"types": "./src/local-interactor-operation-set.ts",
305309
"default": "./src/local-interactor-operation-set.ts"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { isRecord } from '@agent-device/kernel/record';
2+
3+
export type PluginSource = 'builtin' | 'store' | 'embedder';
4+
5+
export type PluginInventoryError = Readonly<{
6+
code: string;
7+
message: string;
8+
reason?: string;
9+
hint?: string;
10+
}>;
11+
12+
export type PluginInventoryCredentials = Readonly<{
13+
variables: readonly string[];
14+
byBackend: Readonly<Record<string, readonly string[]>>;
15+
}>;
16+
17+
/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */
18+
export type PluginInventoryEntry = Readonly<{
19+
provider?: string;
20+
source: PluginSource;
21+
package?: string;
22+
version?: string;
23+
state: 'active' | 'failed';
24+
error?: PluginInventoryError;
25+
credentials: PluginInventoryCredentials;
26+
}>;
27+
28+
export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({
29+
variables: Object.freeze([]),
30+
byBackend: Object.freeze({}),
31+
});
32+
33+
/**
34+
* The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own
35+
* list when the plugin declares one, otherwise its flat list. Undefined when no active plugin
36+
* claims the provider.
37+
*/
38+
export function pluginCredentialVariables(
39+
inventory: readonly PluginInventoryEntry[],
40+
provider: string,
41+
leaseBackend?: string,
42+
): readonly string[] | undefined {
43+
const entry = inventory.find((candidate) => candidate.provider === provider);
44+
if (entry?.state !== 'active') return undefined;
45+
const { variables, byBackend } = entry.credentials;
46+
return (
47+
(leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend)
48+
? byBackend[leaseBackend]
49+
: undefined) ?? variables
50+
);
51+
}
52+
53+
/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */
54+
export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] {
55+
return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : [];
56+
}
57+
58+
function readEntry(value: unknown): PluginInventoryEntry | undefined {
59+
if (
60+
!isRecord(value) ||
61+
!isSource(value.source) ||
62+
(value.state !== 'active' && value.state !== 'failed')
63+
) {
64+
return undefined;
65+
}
66+
const credentials = isRecord(value.credentials) ? value.credentials : {};
67+
return {
68+
...(typeof value.provider === 'string' ? { provider: value.provider } : {}),
69+
source: value.source,
70+
...(typeof value.package === 'string' ? { package: value.package } : {}),
71+
...(typeof value.version === 'string' ? { version: value.version } : {}),
72+
state: value.state,
73+
...(readError(value.error) ? { error: readError(value.error) } : {}),
74+
credentials: {
75+
variables: readNames(credentials.variables),
76+
byBackend: Object.fromEntries(
77+
Object.entries(isRecord(credentials.byBackend) ? credentials.byBackend : {}).map(
78+
([backend, names]) => [backend, readNames(names)],
79+
),
80+
),
81+
},
82+
};
83+
}
84+
85+
function isSource(value: unknown): value is PluginSource {
86+
return value === 'builtin' || value === 'store' || value === 'embedder';
87+
}
88+
89+
function readNames(value: unknown): readonly string[] {
90+
return Array.isArray(value) ? value.filter((name) => typeof name === 'string') : [];
91+
}
92+
93+
function readError(value: unknown): PluginInventoryError | undefined {
94+
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') {
95+
return undefined;
96+
}
97+
return {
98+
code: value.code,
99+
message: value.message,
100+
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
101+
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
102+
};
103+
}

‎packages/daemon-contracts/src/daemon-registration-owner.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type ProcessLockAcquisition,
2323
} from '@agent-device/host-kit/file';
2424
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
25+
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
2526
import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts';
2627
import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature';
2728
import {
@@ -51,6 +52,8 @@ export type DaemonRegistrationFields = Readonly<{
5152
codeOrigin: DaemonCodeOrigin;
5253
codeSignature: string;
5354
policyDigest?: string;
55+
/** The provider plugin inventory, so a client can read what this daemon registered. */
56+
plugins?: readonly PluginInventoryEntry[];
5457
}>;
5558
type DaemonRegistrationRemoval =
5659
| Readonly<{ state: 'removed' }>

‎packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function daemonRuntimeDepsFixture(
6767
hostDiagnostics: hostDiagnosticsFixture(),
6868
ownerLifecycle: platformOwnerLifecycleFixture(),
6969
providerDeviceRuntimes: [],
70-
skippedProviderRuntimes: [],
70+
pluginInventory: [],
7171
providerRuntimeIds: [],
7272
recoverableProviderIds: [],
7373
deviceRuntimeGateway: Object.freeze({

‎packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
12
import fs from 'node:fs';
23
import path from 'node:path';
34
import { afterEach, expect, test, vi } from 'vitest';
@@ -6,7 +7,7 @@ import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/tes
67
const lifecycleEvents = vi.hoisted(() => [] as string[]);
78
const startupFailure = vi.hoisted(() => ({ active: false }));
89
const providerComposition = vi.hoisted(() => ({
9-
skipped: [] as Array<{ provider: string; error: Error & { code: string } }>,
10+
inventory: [] as PluginInventoryEntry[],
1011
}));
1112

1213
import {
@@ -42,7 +43,7 @@ function startRuntime(stateDir: string, exit: (code: number) => void) {
4243
lifecycleEvents.push('gateway-shutdown');
4344
},
4445
}),
45-
skippedProviderRuntimes: providerComposition.skipped,
46+
pluginInventory: providerComposition.inventory,
4647
})),
4748
env: {
4849
...process.env,
@@ -75,7 +76,7 @@ function publishSuccessor(paths: DaemonPaths): void {
7576
afterEach(() => {
7677
startupFailure.active = false;
7778
lifecycleEvents.length = 0;
78-
providerComposition.skipped = [];
79+
providerComposition.inventory = [];
7980
vi.restoreAllMocks();
8081
});
8182

@@ -147,16 +148,26 @@ test('a shutdown removes its own metadata and reports an unverified release', as
147148
}
148149
});
149150

150-
test('a skipped provider runtime is logged after this daemon publishes its record', async () => {
151+
test('a failed provider plugin is logged and published after this daemon publishes its record', async () => {
151152
const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-');
152-
const error = Object.assign(new Error('Limrun instance access is missing X.'), {
153-
code: 'INVALID_ARGS',
154-
});
155-
providerComposition.skipped = [{ provider: 'limrun', error }];
153+
const failed = {
154+
provider: 'limrun',
155+
source: 'builtin',
156+
state: 'failed',
157+
error: { code: 'INVALID_ARGS', message: 'Limrun instance access is missing X.' },
158+
credentials: { variables: [], byBackend: {} },
159+
} as const;
160+
providerComposition.inventory = [failed];
156161
const runtime = await startRuntime(stateDir, () => {});
157162
try {
158-
const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped');
159-
expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' });
163+
const event = logEvents(stateDir).find(({ phase }) => phase === 'provider_plugin_failed');
164+
expect(event?.data).toMatchObject({
165+
provider: 'limrun',
166+
source: 'builtin',
167+
code: 'INVALID_ARGS',
168+
});
169+
const published = JSON.parse(fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'));
170+
expect(published.plugins).toEqual([failed]);
160171
} finally {
161172
await runtime?.shutdown();
162173
}

‎packages/daemon/src/server/http/daemon-runtime.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import type {
9595
ProviderExpiredLeaseRecovery,
9696
} from '@agent-device/contracts/device';
9797
import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics';
98+
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
9899

99100
const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000;
100101
const LEASE_EXPIRY_SWEEP_INTERVAL_MS = 1_000;
@@ -124,11 +125,11 @@ export type DaemonRuntimeDeps = Readonly<{
124125
ownerLifecycle: PlatformOwnerLifecycle;
125126
providerDeviceRuntimes: readonly ProviderDeviceRuntime[];
126127
/**
127-
* Provider runtimes the composition skipped. Data only: this server records each one as a
128-
* `provider_runtime_skipped` diagnostic after publishing its registration, so log emission
129-
* stays in this module.
128+
* Every provider plugin the composition saw, active or failed. Data only: this server publishes
129+
* it in the daemon registration and records each failed entry as a `provider_plugin_failed`
130+
* diagnostic, so log emission stays in this module.
130131
*/
131-
skippedProviderRuntimes: readonly Readonly<{ provider: string; error: AppError }>[];
132+
pluginInventory: readonly PluginInventoryEntry[];
132133
interactorResolution: InteractorResolution;
133134
providerDeviceAdmission: ProviderDeviceAdmission;
134135
cleanupManagedWebOrphans: (
@@ -713,6 +714,7 @@ export async function startDaemonRuntime(
713714
codeOrigin: daemonCodeOrigin,
714715
codeSignature: daemonCodeSignature,
715716
policyDigest: daemonPolicy?.digest,
717+
plugins: deps.pluginInventory,
716718
});
717719
if (socketPort) stdout.write(`AGENT_DEVICE_DAEMON_PORT=${socketPort}\n`);
718720
if (httpPort) stdout.write(`AGENT_DEVICE_DAEMON_HTTP_PORT=${httpPort}\n`);
@@ -798,12 +800,15 @@ export async function startDaemonRuntime(
798800
publishDaemonInfo(socketPort, httpPort);
799801
stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity);
800802
await flushDaemonStartupDiagnostics(logPath, startupDiagnostics);
801-
for (const skipped of deps.skippedProviderRuntimes) {
802-
await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', {
803-
provider: skipped.provider,
804-
code: skipped.error.code,
805-
message: skipped.error.message,
806-
hint: skipped.error.details?.hint,
803+
for (const plugin of deps.pluginInventory.filter(({ state }) => state === 'failed')) {
804+
await emitDaemonDiagnostic(logPath, 'provider_plugin_failed', {
805+
provider: plugin.provider,
806+
package: plugin.package,
807+
source: plugin.source,
808+
code: plugin.error?.code,
809+
reason: plugin.error?.reason,
810+
message: plugin.error?.message,
811+
hint: plugin.error?.hint,
807812
});
808813
}
809814
// After publication: publishDaemonInfo truncates daemon.log, so anything

‎packages/plugins/package.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,18 @@
1515
"types": "./src/connection.ts",
1616
"default": "./src/connection.ts"
1717
},
18+
"./contract": {
19+
"types": "./src/contract.ts",
20+
"default": "./src/contract.ts"
21+
},
1822
"./host": {
1923
"types": "./src/host.ts",
2024
"default": "./src/host.ts"
2125
},
26+
"./inventory": {
27+
"types": "./src/inventory.ts",
28+
"default": "./src/inventory.ts"
29+
},
2230
"./load": {
2331
"types": "./src/load.ts",
2432
"default": "./src/load.ts"
@@ -27,6 +35,10 @@
2735
"types": "./src/manifest.ts",
2836
"default": "./src/manifest.ts"
2937
},
38+
"./registry": {
39+
"types": "./src/registry.ts",
40+
"default": "./src/registry.ts"
41+
},
3042
"./store": {
3143
"types": "./src/store.ts",
3244
"default": "./src/store.ts"

‎packages/plugins/src/connection.ts‎

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,54 @@
1-
import type { CliFlags } from '@agent-device/contracts/command';
2-
import type {
3-
ConnectionProviderCapabilities,
4-
ProviderConnectionVerification,
5-
RemoteConfigProfile,
6-
} from '@agent-device/contracts/remote';
7-
import type { EnvMap } from '@agent-device/kernel/source-value';
8-
import { installedPlugins } from './store.ts';
1+
import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote';
2+
import { resolveUserConfigPath } from '@agent-device/host-kit/file';
3+
import type { PluginConnection, PluginConnectionContext } from './contract.ts';
4+
import { legacyStoreManifests, storePluginCandidates } from './store.ts';
95

10-
export type PluginConnection = Readonly<{
11-
resolve(context: { flags: CliFlags; stateDir: string; cwd: string; env: EnvMap }):
12-
| Promise<{
13-
profile: RemoteConfigProfile;
14-
extraFlags?: Partial<CliFlags>;
15-
}>
16-
| { profile: RemoteConfigProfile; extraFlags?: Partial<CliFlags> };
17-
verify(context: { flags: CliFlags; env: EnvMap }): Promise<ProviderConnectionVerification>;
6+
/** A plugin's connection as the client calls it: the registry adds the `host` to each context. */
7+
export type PluginConnectionCallbacks = Readonly<{
8+
resolve(context: Omit<PluginConnectionContext, 'host'>): ReturnType<PluginConnection['resolve']>;
9+
verify(
10+
context: Omit<Pick<PluginConnectionContext, 'flags' | 'env' | 'host'>, 'host'>,
11+
): ReturnType<PluginConnection['verify']>;
1812
}>;
1913

14+
export type RegisteredConnection = PluginConnectionCallbacks &
15+
Readonly<{ capabilities: PluginConnection['capabilities'] }>;
16+
17+
// Capabilities live in the plugin, so a v2 store plugin answers only once its package has loaded.
18+
// A client loads the store once per process through primePluginConnections; every synchronous
19+
// reader below then answers from that registry, and a v1 plugin answers from its manifest.
20+
const primed = new Map<string, ReadonlyMap<string, RegisteredConnection>>();
21+
22+
/** Loads the store's plugins into the connection registry this process reads capabilities from. */
23+
export async function primePluginConnections(
24+
env: NodeJS.ProcessEnv = process.env,
25+
builtinProviders: readonly string[] = [],
26+
): Promise<void> {
27+
if (primed.has(resolveUserConfigPath(env))) return;
28+
const { claimProviderIds, resolveProviderPlugins } = await import('./registry.ts');
29+
const resolved = await resolveProviderPlugins([
30+
...claimProviderIds(builtinProviders),
31+
...storePluginCandidates(env),
32+
]);
33+
primed.set(resolveUserConfigPath(env), resolved.connections);
34+
}
35+
2036
export function pluginConnectionCapabilities(
2137
provider: string | undefined,
2238
env: NodeJS.ProcessEnv = process.env,
2339
): ConnectionProviderCapabilities | undefined {
24-
return provider === undefined
25-
? undefined
26-
: installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider)
27-
?.agentDevicePlugin.connection;
40+
if (provider === undefined) return undefined;
41+
return (
42+
primed.get(resolveUserConfigPath(env))?.get(provider)?.capabilities ??
43+
legacyStoreManifests(env).find((plugin) => plugin.agentDevicePlugin.provider === provider)
44+
?.agentDevicePlugin.connection
45+
);
2846
}
2947

3048
export function pluginConnectionNames(env: NodeJS.ProcessEnv = process.env): string[] {
31-
return installedPlugins(env)
32-
.filter((plugin) => plugin.agentDevicePlugin.connection)
33-
.map((plugin) => plugin.agentDevicePlugin.provider);
49+
const names = new Set<string>(primed.get(resolveUserConfigPath(env))?.keys());
50+
for (const plugin of legacyStoreManifests(env)) {
51+
if (plugin.agentDevicePlugin.connection) names.add(plugin.agentDevicePlugin.provider);
52+
}
53+
return [...names];
3454
}

0 commit comments

Comments
 (0)