Skip to content

Commit deae993

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 deae993

60 files changed

Lines changed: 2586 additions & 676 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect, test } from 'vitest';
2+
import { pluginCredentialVariables, readPluginInventory } from './plugin-inventory.ts';
3+
4+
const credentials = { variables: ['A'], byBackend: { ios: ['A', 'B'] } };
5+
6+
test('the reader keeps well-formed entries and drops entries that lose plugin state', () => {
7+
const active = { provider: 'p', source: 'store', state: 'active', credentials };
8+
const failed = {
9+
source: 'builtin',
10+
state: 'failed',
11+
error: { code: 'INVALID_ARGS', message: 'bad', reason: 'invalid_plugin' },
12+
credentials,
13+
};
14+
expect(
15+
readPluginInventory([
16+
active,
17+
failed,
18+
{ ...active, credentials: undefined },
19+
{ ...active, credentials: { variables: 'A' } },
20+
{ ...failed, error: undefined },
21+
{ ...active, state: 'unknown' },
22+
'not an entry',
23+
]),
24+
).toEqual([active, failed]);
25+
});
26+
27+
test('credential variables narrow to the lease backend of an active plugin only', () => {
28+
const inventory = readPluginInventory([
29+
{ provider: 'p', source: 'store', state: 'active', credentials },
30+
{
31+
provider: 'q',
32+
source: 'store',
33+
state: 'failed',
34+
error: { code: 'X', message: 'm' },
35+
credentials,
36+
},
37+
]);
38+
expect(pluginCredentialVariables(inventory, 'p', 'ios')).toEqual(['A', 'B']);
39+
expect(pluginCredentialVariables(inventory, 'p', 'android')).toEqual(['A']);
40+
expect(pluginCredentialVariables(inventory, 'q')).toBeUndefined();
41+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
type PluginInventoryIdentity = Readonly<{
18+
provider?: string;
19+
source: PluginSource;
20+
package?: string;
21+
version?: string;
22+
credentials: PluginInventoryCredentials;
23+
}>;
24+
25+
/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */
26+
export type PluginInventoryEntry =
27+
| (PluginInventoryIdentity & Readonly<{ state: 'active'; error?: undefined }>)
28+
| (PluginInventoryIdentity & Readonly<{ state: 'failed'; error: PluginInventoryError }>);
29+
30+
export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({
31+
variables: Object.freeze([]),
32+
byBackend: Object.freeze({}),
33+
});
34+
35+
/**
36+
* The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own
37+
* list when the plugin declares one, otherwise its flat list. Undefined when no active plugin
38+
* claims the provider.
39+
*/
40+
export function pluginCredentialVariables(
41+
inventory: readonly PluginInventoryEntry[],
42+
provider: string,
43+
leaseBackend?: string,
44+
): readonly string[] | undefined {
45+
const entry = inventory.find((candidate) => candidate.provider === provider);
46+
if (entry?.state !== 'active') return undefined;
47+
const { variables, byBackend } = entry.credentials;
48+
return (
49+
(leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend)
50+
? byBackend[leaseBackend]
51+
: undefined) ?? variables
52+
);
53+
}
54+
55+
/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */
56+
export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] {
57+
return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : [];
58+
}
59+
60+
function readEntry(value: unknown): PluginInventoryEntry | undefined {
61+
if (!isRecord(value) || !isSource(value.source)) return undefined;
62+
const credentials = readCredentials(value.credentials);
63+
if (!credentials) return undefined;
64+
const identity = {
65+
...(typeof value.provider === 'string' ? { provider: value.provider } : {}),
66+
source: value.source,
67+
...(typeof value.package === 'string' ? { package: value.package } : {}),
68+
...(typeof value.version === 'string' ? { version: value.version } : {}),
69+
credentials,
70+
};
71+
if (value.state === 'active') return { ...identity, state: 'active' };
72+
const error = readError(value.error);
73+
return value.state === 'failed' && error ? { ...identity, state: 'failed', error } : undefined;
74+
}
75+
76+
function readCredentials(value: unknown): PluginInventoryCredentials | undefined {
77+
if (!isRecord(value)) return undefined;
78+
const variables = readNames(value.variables);
79+
const byBackend = value.byBackend === undefined ? {} : value.byBackend;
80+
if (!variables || !isRecord(byBackend)) return undefined;
81+
const entries = Object.entries(byBackend).map(([backend, names]) => [backend, readNames(names)]);
82+
if (entries.some(([, names]) => names === undefined)) return undefined;
83+
return {
84+
variables,
85+
byBackend: Object.fromEntries(entries) as Record<string, readonly string[]>,
86+
};
87+
}
88+
89+
function isSource(value: unknown): value is PluginSource {
90+
return value === 'builtin' || value === 'store' || value === 'embedder';
91+
}
92+
93+
function readNames(value: unknown): readonly string[] | undefined {
94+
return Array.isArray(value) && value.every((name) => typeof name === 'string')
95+
? value
96+
: undefined;
97+
}
98+
99+
function readError(value: unknown): PluginInventoryError | undefined {
100+
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') {
101+
return undefined;
102+
}
103+
return {
104+
code: value.code,
105+
message: value.message,
106+
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
107+
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
108+
};
109+
}

‎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"

0 commit comments

Comments
 (0)