Skip to content

Commit 06d6853

Browse files
grypezclaude
andcommitted
feat(kernel-agents): author built-in capabilities as pattern-guarded exos
Rewrite the `math`, `end`, and `examples` capabilities as discoverable exos built with the `described*()` combinators, so each capability's argument shape is enforced by the exo's interface guard at invocation rather than only advertised in the prompt. A mistyped argument now fails with a guard rejection at the membrane instead of surfacing deep inside the capability. Each module derives its `{ func, schema }` capability specs via a new synchronous `makeInternalCapabilities` constructor, which builds the pattern-guarded exo and projects a capability record from the just-authored schemas — without round-tripping through `GET_DESCRIPTION`. The exo is kept private as the in-realm enforcement membrane; internal capabilities are guarded closures, not passable exos (to cross a boundary, publish an exo and `discover` it). All existing consumers (example transcripts, e2e tests, the REPL evaluator, prepare-attempt) keep the same spec shape and `makeEnd` stays synchronous. `end`'s closed-over result object is intentionally left un-hardened so the exo method can mutate it. `makeInternalCapabilities` asserts at construction that the implementation and schema method sets match exactly, so an authoring typo (an implementation without a matching schema, which the guard's `defaultGuards: 'passable'` would otherwise accept as an unreachable passable method) fails loudly instead of surfacing as a capability that silently resolves to `undefined`. A colocated `discover.test.ts` covers the positional-arg mapping, guard rejection at the membrane, and this construction-time check. `getMoonPhase` loses its (already unsupported, `@ts-expect-error`'d) `enum` return hint; `end`'s off-spec per-argument `required` flags are gone, with `final` required and `attachments` optional expressed by the guard. Install the endoify mock as a package-wide vitest setup so capability modules, which now build exos at import, have a `harden` global before they load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8989cee commit 06d6853

7 files changed

Lines changed: 320 additions & 140 deletions

File tree

packages/kernel-agents/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- The built-in capabilities (`math`, `end`, `examples`) are now pattern-guarded discoverable exos authored with the `described*()` combinators, so their argument shapes are enforced by the exo's interface guard at invocation rather than only described in the prompt ([#959](https://github.com/MetaMask/ocap-kernel/pull/959))
13+
1014
[Unreleased]: https://github.com/MetaMask/ocap-kernel/
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { S } from '@metamask/kernel-utils';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { makeInternalCapabilities } from './discover.ts';
5+
6+
/**
7+
* Whether a promise rejects. Used instead of `.rejects.toThrow()` because the
8+
* interface guard rejects with an opaque (non-`Error`) value under the test
9+
* shim; here we only care that the membrane blocked the call.
10+
*
11+
* @param promise - The promise to observe.
12+
* @returns `true` if the promise rejects, `false` if it resolves.
13+
*/
14+
const rejects = async (promise: Promise<unknown>): Promise<boolean> => {
15+
try {
16+
await promise;
17+
return false;
18+
} catch {
19+
return true;
20+
}
21+
};
22+
23+
// Build the capabilities once, mirroring how the built-in capability modules
24+
// construct their exo at import.
25+
const capabilities = makeInternalCapabilities(
26+
'Test',
27+
{
28+
async count(word: string) {
29+
return word.length;
30+
},
31+
async add(summands: number[]) {
32+
return summands.reduce((acc, summand) => acc + summand, 0);
33+
},
34+
},
35+
S.interface('Test', {
36+
count: S.method(
37+
'Count characters.',
38+
[S.arg('word', S.string('The string to measure.'))],
39+
S.number('The number of characters.'),
40+
),
41+
add: S.method(
42+
'Add numbers.',
43+
[S.arg('summands', S.arrayOf(S.number()))],
44+
S.number('The sum.'),
45+
),
46+
}),
47+
);
48+
49+
describe('makeInternalCapabilities', () => {
50+
it('projects a capability record keyed by the schema method names', () => {
51+
expect(Object.keys(capabilities)).toStrictEqual(['count', 'add']);
52+
expect(capabilities.count.schema.description).toBe('Count characters.');
53+
});
54+
55+
it('maps the named-args object to positional args and invokes the method', async () => {
56+
expect(await capabilities.count.func({ word: 'abcdefg' })).toBe(7);
57+
expect(await capabilities.add.func({ summands: [1, 2, 3, 4] })).toBe(10);
58+
});
59+
60+
it('rejects a mistyped argument at the exo membrane', async () => {
61+
// `count` expects a string; the interface guard rejects a number before the
62+
// implementation runs.
63+
expect(
64+
await rejects(capabilities.count.func({ word: 12345 } as never)),
65+
).toBe(true);
66+
});
67+
68+
it('rejects a missing required argument at the exo membrane', async () => {
69+
expect(await rejects(capabilities.add.func({} as never))).toBe(true);
70+
});
71+
72+
it('throws at construction when an implementation has no matching schema', () => {
73+
expect(() =>
74+
makeInternalCapabilities(
75+
'Skewed',
76+
{
77+
// Typo: the schema declares `search`, not `serch`.
78+
async serch(query: string) {
79+
return query;
80+
},
81+
},
82+
S.interface('Skewed', {
83+
search: S.method('Search.', [S.arg('query', S.string())], S.string()),
84+
}),
85+
),
86+
).toThrow(/implementation and schema method names must match/u);
87+
});
88+
});
Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,58 @@
11
import { E } from '@endo/eventual-send';
2-
import { GET_DESCRIPTION } from '@metamask/kernel-utils';
3-
import type { DiscoverableExo, MethodSchema } from '@metamask/kernel-utils';
2+
import { GET_DESCRIPTION, makeDiscoverableExo } from '@metamask/kernel-utils';
3+
import type {
4+
DescribedInterface,
5+
DiscoverableExo,
6+
MethodSchema,
7+
} from '@metamask/kernel-utils';
48

59
import type { CapabilityRecord, CapabilitySpec } from '../types.ts';
610

711
/**
8-
* Discover the capabilities of a discoverable exo. Intended for use from inside a vat.
9-
* This function fetches the schema from the discoverable exo and creates capabilities that can be used by kernel agents.
12+
* Invoke a discoverable exo's method with positional arguments. The async
13+
* variant ({@link discover}) sends over an eventual-send boundary; the local
14+
* variant ({@link makeInternalCapabilities}) calls the in-realm exo directly.
15+
* Either way the exo's interface guard enforces the argument shape.
16+
*/
17+
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;
18+
19+
/**
20+
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
21+
* capability's object arguments to positional arguments for the exo method.
22+
*
23+
* IMPORTANT: this relies on each `schema.args` having keys in the same order as
24+
* the method's parameters. Schemas authored with the `described*()` combinators
25+
* (`@metamask/kernel-utils`) satisfy this by construction, since their `args`
26+
* record is built in declared positional order.
27+
*
28+
* @param description - The exo's method schemas, keyed by method name.
29+
* @param invoke - How to invoke a method with positional arguments.
30+
* @returns The capability record.
31+
*/
32+
const capabilitiesFrom = (
33+
description: Record<string, MethodSchema>,
34+
invoke: Invoke,
35+
): CapabilityRecord =>
36+
Object.fromEntries(
37+
Object.entries(description).map(([name, schema]) => {
38+
const argNames = Object.keys(schema.args);
39+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
40+
const func = async (args: Record<string, unknown>) =>
41+
invoke(
42+
name,
43+
argNames.map((argName) => args[argName]),
44+
);
45+
return [name, { func, schema }] as [
46+
string,
47+
CapabilitySpec<never, unknown>,
48+
];
49+
}),
50+
);
51+
52+
/**
53+
* Discover the capabilities of a (possibly remote) discoverable exo. Fetches the
54+
* schema over an eventual-send boundary and creates capabilities that invoke the
55+
* exo's methods the same way.
1056
*
1157
* @param exo - The discoverable exo to convert to a capability record.
1258
* @returns A promise for a capability record.
@@ -19,35 +65,68 @@ export const discover = async (
1965
string,
2066
MethodSchema
2167
>;
22-
23-
const capabilities: CapabilityRecord = Object.fromEntries(
24-
Object.entries(description).map(([name, schema]) => {
25-
// Get argument names in order from the schema.
26-
// IMPORTANT: This relies on the schema's args object having keys in the same
27-
// order as the method's parameters. The schema must be defined with argument
28-
// names matching the method parameter order (e.g., for method `add(a, b)`,
29-
// the schema must have `args: { a: ..., b: ... }` in that order).
30-
// JavaScript objects preserve insertion order for string keys, so Object.keys()
31-
// will return keys in the order they were defined in the schema.
32-
const argNames = Object.keys(schema.args);
33-
34-
// Create a capability function that accepts an args object
35-
// and maps it to positional arguments for the exo method
36-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
37-
const func = async (args: Record<string, unknown>) => {
38-
// Map object arguments to positional arguments in schema order.
39-
// The order of argNames matches the method parameter order by convention.
40-
const positionalArgs = argNames.map((argName) => args[argName]);
41-
// @ts-expect-error - E type doesn't remember method names
42-
return E(exo)[name](...positionalArgs);
43-
};
44-
45-
return [name, { func, schema }] as [
46-
string,
47-
CapabilitySpec<never, unknown>,
48-
];
49-
}),
68+
return capabilitiesFrom(description, async (method, positionalArgs) =>
69+
// @ts-expect-error - E type doesn't remember method names
70+
E(exo)[method](...positionalArgs),
5071
);
72+
};
5173

52-
return capabilities;
74+
/**
75+
* Construct an in-realm capability record from a guard+schema description and
76+
* the method implementations, building (and then keeping private) the
77+
* pattern-guarded exo that enforces the argument shape on every call.
78+
*
79+
* Unlike {@link discover}, this never crosses an eventual-send boundary and
80+
* never reads `GET_DESCRIPTION`: the schemas are the ones just authored with the
81+
* `described*()` combinators (`@metamask/kernel-utils`), so there is no
82+
* round-trip through the exo to recover what the caller already holds. The exo
83+
* is used purely as the in-realm enforcement membrane and is not surfaced —
84+
* internal capabilities are guarded closures, not passable exos. To expose a
85+
* capability across a boundary, publish a {@link DiscoverableExo} and
86+
* {@link discover} it instead.
87+
*
88+
* @param name - The exo/interface name.
89+
* @param methods - The method implementations, keyed by method name.
90+
* @param described - The interface guard and per-method schemas, e.g. from
91+
* `S.interface(...)`.
92+
* @returns A capability record keyed by the method names.
93+
*/
94+
export const makeInternalCapabilities = <Method extends string>(
95+
name: string,
96+
methods: Record<Method, (...args: never[]) => Promise<unknown>>,
97+
described: DescribedInterface,
98+
): CapabilityRecord<Method> => {
99+
const { interfaceGuard, schemas } = described;
100+
// The implementation and schema method sets must match exactly. A missing
101+
// implementation already throws inside `makeDiscoverableExo`, but an extra
102+
// implementation absent from the schema is silently accepted by the guard's
103+
// `defaultGuards: 'passable'` and would never be reachable as a capability.
104+
// Catch both here so an authoring typo (e.g. `serch` vs `search`) fails loudly
105+
// at construction instead of surfacing as a capability that resolves to
106+
// `undefined`.
107+
const missing = Object.keys(schemas).filter((method) => !(method in methods));
108+
const extra = Object.keys(methods).filter((method) => !(method in schemas));
109+
if (missing.length > 0 || extra.length > 0) {
110+
throw new Error(
111+
`makeInternalCapabilities("${name}"): implementation and schema method names must match. ` +
112+
`Schema methods without an implementation: [${missing.join(', ')}]; ` +
113+
`implementations without a schema: [${extra.join(', ')}].`,
114+
);
115+
}
116+
const exo = makeDiscoverableExo(
117+
name,
118+
methods as Record<string, (...args: unknown[]) => unknown>,
119+
schemas,
120+
interfaceGuard,
121+
);
122+
const dispatch = exo as unknown as Record<
123+
string,
124+
(...args: unknown[]) => unknown
125+
>;
126+
// Dispatch as a member call so the exo method keeps its `this` binding. The
127+
// construction check above guarantees `method` is present, so the optional
128+
// chain never short-circuits in practice.
129+
return capabilitiesFrom(schemas, (method, positionalArgs) =>
130+
dispatch[method]?.(...positionalArgs),
131+
) as CapabilityRecord<Method>;
53132
};

packages/kernel-agents/src/capabilities/end.ts

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { S } from '@metamask/kernel-utils';
2+
3+
import { makeInternalCapabilities } from './discover.ts';
14
import { ifDefined } from '../utils.ts';
2-
import { capability } from './capability.ts';
35

46
/**
57
* A factory function to make a task's `end` capability, which stores the first
@@ -10,36 +12,41 @@ import { capability } from './capability.ts';
1012
*/
1113
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
1214
export const makeEnd = <Result>() => {
15+
// Captured, mutable state for the first final result. Intentionally NOT
16+
// hardened: the exo method below closes over and mutates it.
1317
const result: { final?: Result; attachments?: Record<string, unknown> } = {};
14-
const end = capability(
15-
async ({
16-
final,
17-
attachments,
18-
}: {
19-
final: Result;
20-
attachments?: Record<string, unknown>;
21-
}): Promise<void> => {
22-
if (!Object.hasOwn(result, 'final')) {
23-
Object.assign(result, { final, ...ifDefined({ attachments }) });
24-
}
25-
},
18+
19+
const { end } = makeInternalCapabilities(
20+
'End',
2621
{
27-
description: 'Return a final response to the user.',
28-
args: {
29-
final: {
30-
required: true,
31-
type: 'string',
32-
description:
33-
'A concise final response that restates the requested information.',
34-
},
35-
attachments: {
36-
required: false,
37-
type: 'object',
38-
description: 'Attachments to the final response.',
39-
},
22+
async end(
23+
final: Result,
24+
attachments?: Record<string, unknown>,
25+
): Promise<void> {
26+
if (!Object.hasOwn(result, 'final')) {
27+
Object.assign(result, { final, ...ifDefined({ attachments }) });
28+
}
4029
},
4130
},
31+
S.interface('End', {
32+
end: S.method(
33+
'Return a final response to the user.',
34+
[
35+
S.arg(
36+
'final',
37+
S.string(
38+
'A concise final response that restates the requested information.',
39+
),
40+
),
41+
S.arg('attachments', S.record('Attachments to the final response.'), {
42+
optional: true,
43+
}),
44+
],
45+
S.nothing(),
46+
),
47+
}),
4248
);
49+
4350
return [end, () => 'final' in result, () => result.final as Result] as const;
4451
};
4552

0 commit comments

Comments
 (0)