@matatbread/matbot-plugin-api 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/errors.ts +28 -1
- package/src/notify.ts +23 -0
- package/src/principal-context.ts +72 -3
- package/src/types/tools.ts +11 -0
- package/src/types/vault.ts +12 -0
- package/src/vault.ts +30 -0
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const BRAND = 'matbot';
|
|
18
18
|
|
|
19
|
-
export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly' | 'MediaRejected';
|
|
19
|
+
export type MatbotErrorKind = 'MissingSecret' | 'InvalidSecretName' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly' | 'MediaRejected';
|
|
20
20
|
|
|
21
21
|
function isKind(e: unknown, kind: MatbotErrorKind): boolean {
|
|
22
22
|
return typeof e === 'object' && e !== null && (e as Record<string, unknown>)[BRAND] === kind;
|
|
@@ -40,6 +40,33 @@ export function isMissingSecretError(e: unknown): e is MissingSecretError {
|
|
|
40
40
|
return isKind(e, 'MissingSecret');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Thrown by a vault write when the backend cannot store a secret under the requested name — the
|
|
45
|
+
* name is the caller's, and most callers are an LLM inventing one, so it is regularly unstorable
|
|
46
|
+
* (a `.env`-backed vault takes environment-variable names; `email:abc:password` is silently
|
|
47
|
+
* dropped by systemd on the next boot). Refusing at the write is the only point that still knows
|
|
48
|
+
* which name was asked for.
|
|
49
|
+
*
|
|
50
|
+
* `rule` states what IS storable, not what went wrong: it is written for whoever must pick another
|
|
51
|
+
* name, and the caller that surfaces this — a prompt, a tool error — has no other source for it.
|
|
52
|
+
* Which names qualify is the backend's own business (`VaultSpec.unstorableKey`), so `rule` comes
|
|
53
|
+
* from there verbatim.
|
|
54
|
+
*/
|
|
55
|
+
export interface InvalidSecretNameError extends Error {
|
|
56
|
+
matbot: 'InvalidSecretName';
|
|
57
|
+
/** The rejected name. `key`, not `name` — `Error.name` is already the error's own type name. */
|
|
58
|
+
readonly key: string;
|
|
59
|
+
readonly rule: string;
|
|
60
|
+
}
|
|
61
|
+
export function invalidSecretNameError(key: string, rule: string): InvalidSecretNameError {
|
|
62
|
+
const e = new Error(`Vault: cannot store a secret named "${key}" — ${rule}`);
|
|
63
|
+
e.name = 'InvalidSecretNameError';
|
|
64
|
+
return Object.assign(e, { matbot: 'InvalidSecretName' as const, key, rule });
|
|
65
|
+
}
|
|
66
|
+
export function isInvalidSecretNameError(e: unknown): e is InvalidSecretNameError {
|
|
67
|
+
return isKind(e, 'InvalidSecretName');
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
/**
|
|
44
71
|
* Thrown by the loader when an explicit, single, user-initiated load (the `plugin`/`provider`
|
|
45
72
|
* tools via `services.loadPlugin`) targets a plugin whose `matbotRuntime` excludes this host.
|
package/src/notify.ts
CHANGED
|
@@ -54,6 +54,21 @@ export interface NotificationBase {
|
|
|
54
54
|
export interface ItemChange extends NotificationBase {
|
|
55
55
|
readonly namespace: string;
|
|
56
56
|
readonly id: string;
|
|
57
|
+
/**
|
|
58
|
+
* The **caller's** key for this item, where `id` is derived from it rather than equal to it.
|
|
59
|
+
*
|
|
60
|
+
* `id` is the medium's address, and a medium is entitled to shape it: settings namespaces are plugin
|
|
61
|
+
* package names slugged to satisfy the filesystem store's `/^[\w-]+$/`, and another backend could
|
|
62
|
+
* legitimately hash or truncate. A consumer routing on identity — "is this MY document?" — otherwise
|
|
63
|
+
* has to reproduce a transformation it does not own, in a copy that keeps compiling after the rule
|
|
64
|
+
* changes and simply stops matching. That failure is silent and indistinguishable from "nothing
|
|
65
|
+
* changed", which is the opposite of what an invalidation is for.
|
|
66
|
+
*
|
|
67
|
+
* So the producer states the key it was addressed by, and a consumer compares names. Present only
|
|
68
|
+
* where the two genuinely differ — `sessions` and `files` address items by the id they were given,
|
|
69
|
+
* and an absent `key` means exactly that.
|
|
70
|
+
*/
|
|
71
|
+
readonly key?: string;
|
|
57
72
|
readonly operation: 'saved' | 'deleted';
|
|
58
73
|
readonly detail?: unknown;
|
|
59
74
|
}
|
|
@@ -221,14 +236,22 @@ export function scopedNotifier(notifier: Notifier, plugin: string): Notifier {
|
|
|
221
236
|
* partition for visibility filtering). It observes writes made THROUGH it — a second process, or a
|
|
222
237
|
* writer that bypassed this store, is not seen; that is what a backend's own watch, where one exists,
|
|
223
238
|
* is for.
|
|
239
|
+
*
|
|
240
|
+
* `keyOf` supplies {@link ItemChange.key} for a namespace whose document ids are DERIVED from the
|
|
241
|
+
* caller's key (settings: a plugin name slugged to a legal id) — a function of the id rather than a
|
|
242
|
+
* constant, because this wraps a whole store and a per-document answer is the only honest shape.
|
|
243
|
+
* Omit it where the id IS the key, which is every other namespace today.
|
|
224
244
|
*/
|
|
225
245
|
export function notifyingStore<T extends { id: string; version: string }>(
|
|
226
246
|
store: Store<T>, notifier: Notifier, namespace: string, source: string,
|
|
247
|
+
keyOf?: (id: string) => string | undefined,
|
|
227
248
|
): Store<T> {
|
|
228
249
|
const announce = (operation: 'saved' | 'deleted', id: string): void => {
|
|
229
250
|
const principal = tryCurrentPrincipal();
|
|
251
|
+
const key = keyOf?.(id);
|
|
230
252
|
notifier.notify({
|
|
231
253
|
kind: ItemChangeKind, source, operation, namespace, id,
|
|
254
|
+
...(key !== undefined ? { key } : {}),
|
|
232
255
|
...(principal !== undefined ? { principal } : {}),
|
|
233
256
|
});
|
|
234
257
|
};
|
package/src/principal-context.ts
CHANGED
|
@@ -23,8 +23,9 @@ export interface PrincipalCarrier {
|
|
|
23
23
|
/**
|
|
24
24
|
* Establish `principal` for the dynamic extent of `fn` and return whatever `fn` returns. On node
|
|
25
25
|
* this is per-async-flow (concurrent turns stay isolated); nesting cleanly shadows-and-restores,
|
|
26
|
-
* which is how in-flow delegation works.
|
|
27
|
-
*
|
|
26
|
+
* which is how in-flow delegation works. An implementation establishes the scope and nothing more:
|
|
27
|
+
* deferred work returned out of it (an unpulled iterator, a promise of one) is {@link runAs}'s to
|
|
28
|
+
* re-establish, and doing it here as well would only nest the same identity twice.
|
|
28
29
|
*/
|
|
29
30
|
run<T>(principal: Principal, fn: () => T): T;
|
|
30
31
|
/**
|
|
@@ -71,7 +72,75 @@ export function tryCurrentPrincipal(): Principal | undefined {
|
|
|
71
72
|
|
|
72
73
|
/** Run `fn` with `principal` established as the ambient identity for its async extent. */
|
|
73
74
|
export function runAs<T>(principal: Principal, fn: () => T): T {
|
|
74
|
-
|
|
75
|
+
const carrier = need();
|
|
76
|
+
return carrier.run(principal, () => rescope(carrier, principal, fn()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A generator's body does not begin until the first pull, so an iterator returned out of the scope carries
|
|
80
|
+
// its whole extent with it: `fn` established the identity for the *construction*, and the work then runs in
|
|
81
|
+
// whatever flow happens to pull it. The tool ABI returns exactly that shape (`executor.execute()`), so the
|
|
82
|
+
// natural thing to write at a tool seam was the broken thing — silently, since a host that entered a boot
|
|
83
|
+
// principal reads a plausible one rather than throwing. Re-establishing per pull rather than requiring the
|
|
84
|
+
// caller to consume inside `fn`: the principal is a re-entrant label that grants nothing, and nesting
|
|
85
|
+
// already shadows-and-restores, so re-entering costs nothing semantically — which is also what makes nesting
|
|
86
|
+
// unremarkable: `runAs(A, () => runAs(B, () => gen()))` stacks two wrappers and every pull resolves to B,
|
|
87
|
+
// the same answer plain nesting gives with no iterator in sight. Deliberately NOT extended to
|
|
88
|
+
// `machineBusy` or `withUsageScope`, whose shape is identical but whose hold and roll-up settle when `fn`
|
|
89
|
+
// does — a resource with a settle edge cannot be re-entered per pull, only an identity can.
|
|
90
|
+
function rescope<T>(carrier: PrincipalCarrier, principal: Principal, value: T): T {
|
|
91
|
+
if (isAsyncIterator(value)) return scopedIterator(carrier, principal, value) as T;
|
|
92
|
+
// `async () => execute(...)` defers the identical body behind one await. This DERIVES a promise rather
|
|
93
|
+
// than intercepting one, which is why a single `then` covers it: `catch`/`finally` on what the caller
|
|
94
|
+
// receives are downstream of the rescope, and a rejection is a value rather than a deferred body, so an
|
|
95
|
+
// `onRejected` would have nothing to re-establish and would cost the rejection's identity. Native only —
|
|
96
|
+
// `PromiseLike.then` need not return a promise, so adopting a thenable would replace an object the caller
|
|
97
|
+
// may use for more than awaiting (a chainable query builder), and `async` produces the native one anyway.
|
|
98
|
+
if (value instanceof Promise) return value.then(inner => rescope(carrier, principal, inner)) as T;
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Iterator-shaped, not merely async-iterable. A ReadableStream is `for await`-able too, and CAN be proxied
|
|
103
|
+
// (measured: `instanceof`, `pipeTo` and a platform `new Response(stream)` all survive one) — but only two of
|
|
104
|
+
// its pull paths could then be re-entered, `[Symbol.asyncIterator]` and `getReader().read()`, while
|
|
105
|
+
// `pipeTo`/`pipeThrough`/`tee` pull from platform internals no wrapper reaches. A conditional guarantee is
|
|
106
|
+
// worse than none in an identity primitive: a host testing with `for await` and shipping `pipeTo` would
|
|
107
|
+
// regress silently. Its eager half is safe either way — `start()` runs at construction, inside the scope —
|
|
108
|
+
// so what is uncovered is a lazy `pull()` that reads the principal, which no in-repo streamer is: every one
|
|
109
|
+
// is an async generator, and `FileHandle.stream()` is `AsyncIterable` by contract. Carrying `next` is then
|
|
110
|
+
// taken to mean the value IS the iterator — what a caller holding `execute()`'s result has — rather than a
|
|
111
|
+
// factory for fresh ones, a thing it cannot also be.
|
|
112
|
+
function isAsyncIterator(value: unknown): value is AsyncIterator<unknown> & AsyncIterable<unknown> {
|
|
113
|
+
return typeof value === 'object' && value !== null
|
|
114
|
+
&& typeof (value as AsyncIterable<unknown>)[Symbol.asyncIterator] === 'function'
|
|
115
|
+
&& typeof (value as AsyncIterator<unknown>).next === 'function';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function scopedIterator<T>(
|
|
119
|
+
carrier: PrincipalCarrier,
|
|
120
|
+
principal: Principal,
|
|
121
|
+
source: AsyncIterator<T> & AsyncIterable<T>,
|
|
122
|
+
): AsyncIterableIterator<T> {
|
|
123
|
+
const inScope = <R>(f: () => R): R => carrier.run(principal, f);
|
|
124
|
+
// A Proxy rather than a fresh object literal: the value crossing back out must still BE what it was —
|
|
125
|
+
// a custom iterator's own methods, its prototype, `instanceof` — so only the pull points are replaced.
|
|
126
|
+
// Anything else is forwarded bound to the target, which a class-based iterator holding internal state
|
|
127
|
+
// (or an object with internal slots) requires.
|
|
128
|
+
const pulls: Record<PropertyKey, unknown> = {
|
|
129
|
+
[Symbol.asyncIterator]: () => proxy,
|
|
130
|
+
next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
|
|
131
|
+
...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
|
|
132
|
+
...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
|
|
133
|
+
};
|
|
134
|
+
const proxy = new Proxy(source, {
|
|
135
|
+
// `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
|
|
136
|
+
// off the lookup object instead of the iterator being wrapped.
|
|
137
|
+
get(target, prop) {
|
|
138
|
+
if (Object.hasOwn(pulls, prop)) return pulls[prop];
|
|
139
|
+
const value = Reflect.get(target, prop, target);
|
|
140
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
141
|
+
},
|
|
142
|
+
}) as AsyncIterableIterator<T>;
|
|
143
|
+
return proxy;
|
|
75
144
|
}
|
|
76
145
|
|
|
77
146
|
/** Imperatively establish `principal` for the current flow (entry points only). */
|
package/src/types/tools.ts
CHANGED
|
@@ -105,6 +105,17 @@ export interface ToolContract<Result, Args = unknown> {
|
|
|
105
105
|
readonly [ARGS]: Args;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The params of a tool that takes none — an object with no properties.
|
|
110
|
+
*
|
|
111
|
+
* Spelled out because both obvious alternatives are wrong. **`{}` is not the empty object type**: in
|
|
112
|
+
* TypeScript it means "any non-nullish value", so `ToolContract<R, {}>` accepts `'hello'` and `42` and
|
|
113
|
+
* describes nothing. And **omitting `Args` is looser still**: it defaults to `unknown`, which a
|
|
114
|
+
* validator can only pass, so a caller sending `null` — or anything at all — is accepted. Both read as
|
|
115
|
+
* "no parameters" and neither says it.
|
|
116
|
+
*/
|
|
117
|
+
export type NoParams = Record<string, never>;
|
|
118
|
+
|
|
108
119
|
type ToolResultArmed<E> = E extends ToolContract<unknown, unknown> ? true : false;
|
|
109
120
|
type ToolResultUnion<E> = E extends ToolContract<infer R, unknown> ? R : never;
|
|
110
121
|
type ToolResultMatched<E, P> = E extends ToolContract<infer R, infer A> ? (P extends A ? R : never) : never;
|
package/src/types/vault.ts
CHANGED
|
@@ -22,6 +22,18 @@ export interface VaultSpec {
|
|
|
22
22
|
* reverse-index omit it, and `createSecret`'s dedup step is skipped.
|
|
23
23
|
*/
|
|
24
24
|
findByValue?(value: string): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Why this backend cannot store a secret under `name` — `undefined` when it can. What is storable
|
|
27
|
+
* is the *backend's* business: an in-memory map takes anything referenceable, a `.env`-backed one
|
|
28
|
+
* takes environment-variable names, a header-based one may exclude `:`.
|
|
29
|
+
*
|
|
30
|
+
* The returned string is the RULE, phrased for whoever must now choose another name (often an LLM
|
|
31
|
+
* that invented the rejected one), not a restatement of the failure — it is the only place that
|
|
32
|
+
* knowledge exists, and it is what rides on the thrown {@link InvalidSecretNameError}. Enforced by
|
|
33
|
+
* `assertStorableKey`, which every backend's `writeSecret` calls before storing; a backend omitting
|
|
34
|
+
* this method accepts any name.
|
|
35
|
+
*/
|
|
36
|
+
unstorableKey?(name: string): string | undefined;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
export interface Vault extends VaultSpec {
|
package/src/vault.ts
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import type { VaultSpec } from './types.js';
|
|
2
|
+
import { invalidSecretNameError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rule every vault shares, whatever else it adds: a secret is only reachable through a
|
|
6
|
+
* `${NAME}` placeholder, so a name carrying whitespace, `$`, `{` or `}` — or no characters at all —
|
|
7
|
+
* can be written and never read back. Backends narrow this (see `EnvFileVault`); none widen it.
|
|
8
|
+
*/
|
|
9
|
+
export const REFERENCEABLE_KEY_RULE =
|
|
10
|
+
'a secret name must be non-empty and contain no whitespace, `$`, `{` or `}`, so that it can be referenced as ${NAME}';
|
|
11
|
+
|
|
12
|
+
/** The shared base policy, for a backend whose `unstorableKey` adds nothing of its own. */
|
|
13
|
+
export function unreferenceableKey(name: string): string | undefined {
|
|
14
|
+
return /^[^\s${}]+$/.test(name) ? undefined : REFERENCEABLE_KEY_RULE;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Refuse a name the backend cannot store, rather than writing something that silently disappears —
|
|
19
|
+
* the `.env` line systemd discards, the header a proxy strips. Called by every backend's
|
|
20
|
+
* `writeSecret` before it stores, which is the one point that still knows the requested name:
|
|
21
|
+
* `createSecret` may canonicalise it away, and by the time a reader misses the secret there is
|
|
22
|
+
* nothing left to blame.
|
|
23
|
+
*
|
|
24
|
+
* Removal (`writeSecret(name, '')`) deliberately does not go through here — a name that became
|
|
25
|
+
* unstorable, or arrived from an environment snapshot that never passed the policy, must still be
|
|
26
|
+
* deletable.
|
|
27
|
+
*/
|
|
28
|
+
export function assertStorableKey(spec: VaultSpec, name: string): void {
|
|
29
|
+
const rule = spec.unstorableKey?.(name);
|
|
30
|
+
if (rule !== undefined) throw invalidSecretNameError(name, rule);
|
|
31
|
+
}
|
|
2
32
|
|
|
3
33
|
/**
|
|
4
34
|
* The `createSecret` policy, written once over the spec primitives so every backend behaves
|