@matatbread/matbot-plugin-api 0.4.19 → 0.4.21
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/contract-arms.ts +44 -0
- package/src/fetch-origin.ts +58 -0
- package/src/hooks.ts +26 -1
- package/src/index.ts +4 -1
- package/src/invoke-tool.ts +41 -6
- package/src/permission-gate.ts +6 -3
- package/src/provider-models.ts +106 -0
- package/src/provider-patch.ts +40 -3
- package/src/types/builtin-tools.ts +2 -1
- package/src/types/events.ts +3 -0
- package/src/types/hooks.ts +42 -2
- package/src/types/permission.ts +83 -2
- package/src/types/provider.ts +15 -0
- package/src/types/registries.ts +6 -1
- package/src/types/tools.ts +20 -4
- package/src/types/typescript.ts +207 -11
- package/src/types/vault.ts +14 -5
- package/src/vault.ts +17 -1
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The `ToolContracts` arms for tools that carry their contract as a `toolContract` STRING, written out for a
|
|
2
|
+
// file that declares them: the generated dts a body is checked against, the program that types them for
|
|
3
|
+
// validation, a turn's own tools appended to a snippet. One implementation, because each of those must agree
|
|
4
|
+
// on what a string means, and a fourth copy of the namespace rule would drift from the other three.
|
|
5
|
+
//
|
|
6
|
+
// A string may name types that exist nowhere else — a function package's own `interface HubReport`, carried
|
|
7
|
+
// on `toolContractTypes`. Those are declared in a namespace of their own with an alias per tool beside them,
|
|
8
|
+
// so the contract is written exactly as its author wrote it and still resolves, and two packages' `Report`s
|
|
9
|
+
// never meet. Tools carrying identical declarations (one package's exports) share one namespace.
|
|
10
|
+
|
|
11
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
12
|
+
|
|
13
|
+
export interface ContractSource {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly contract: string;
|
|
16
|
+
readonly types?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ContractArms {
|
|
20
|
+
/** Top-level declarations to place ahead of the `ToolContracts` augmentation; empty when nothing has types. */
|
|
21
|
+
readonly preamble: string;
|
|
22
|
+
/** Per tool, the type to write as its `ToolContracts` arm: its own string, or the alias that wraps it. */
|
|
23
|
+
readonly arms: ReadonlyMap<string, string>;
|
|
24
|
+
/** Per namespace in the preamble, the tools it serves — what a diagnostic inside it is attributed to. */
|
|
25
|
+
readonly namespaces: ReadonlyMap<string, readonly string[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function contractArms(tools: Iterable<ContractSource>): ContractArms {
|
|
29
|
+
const arms = new Map<string, string>();
|
|
30
|
+
const groups = new Map<string, { ns: string; members: [alias: string, tool: string, contract: string][] }>();
|
|
31
|
+
for (const t of tools) {
|
|
32
|
+
if (t.types === undefined || t.types.trim() === '') { arms.set(t.name, t.contract); continue; }
|
|
33
|
+
let group = groups.get(t.types);
|
|
34
|
+
if (group === undefined) { group = { ns: `ToolContractTypes${groups.size + 1}`, members: [] }; groups.set(t.types, group); }
|
|
35
|
+
const alias = IDENTIFIER.test(t.name) ? t.name : `Tool${group.members.length + 1}`;
|
|
36
|
+
group.members.push([alias, t.name, t.contract]);
|
|
37
|
+
arms.set(t.name, `${group.ns}.${alias}`);
|
|
38
|
+
}
|
|
39
|
+
const preamble = [...groups].map(([types, g]) =>
|
|
40
|
+
`declare namespace ${g.ns} {\n${types.split('\n').map(l => ` ${l}`).join('\n')}\n`
|
|
41
|
+
+ g.members.map(([alias, , contract]) => ` export type ${alias} = ${contract};`).join('\n') + '\n}\n').join('');
|
|
42
|
+
const namespaces = new Map([...groups.values()].map(g => [g.ns, g.members.map(([, tool]) => tool)] as const));
|
|
43
|
+
return { preamble, arms, namespaces };
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `fetch`, with every URL a request reaches — the one asked for and each one a redirect leads to — put to
|
|
2
|
+
// `admit` before anything is sent there. A gate on the first URL alone is a gate on nothing: an approved
|
|
3
|
+
// host that answers 302 hands the request, and whatever it carries, to an origin nobody was asked about.
|
|
4
|
+
//
|
|
5
|
+
// So a `follow` request (the default) is followed here, one hop at a time, as the platform would: 303, and
|
|
6
|
+
// 301/302 after a POST, become a bodiless GET; 307/308 resend the body; credentials are dropped on a change
|
|
7
|
+
// of origin; twenty hops at most. What is lost is cosmetic: the final `Response` reports the last hop's URL
|
|
8
|
+
// and `redirected: false`. A `manual` or `error` request is one hop, admitted and handed to `fetch` as is.
|
|
9
|
+
//
|
|
10
|
+
// A browser's `manual` redirect is opaque, with no Location to read, so there it is refused rather than
|
|
11
|
+
// followed blind.
|
|
12
|
+
|
|
13
|
+
const MAX_REDIRECTS = 20;
|
|
14
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
15
|
+
const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location'];
|
|
16
|
+
const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization'];
|
|
17
|
+
|
|
18
|
+
export async function originGatedFetch(
|
|
19
|
+
admit: (url: URL) => Promise<void>,
|
|
20
|
+
input: RequestInfo | URL,
|
|
21
|
+
init?: RequestInit,
|
|
22
|
+
fetchImpl: typeof fetch = fetch,
|
|
23
|
+
): Promise<Response> {
|
|
24
|
+
const first = new Request(input, init);
|
|
25
|
+
await admit(new URL(first.url));
|
|
26
|
+
if (first.redirect !== 'follow') return fetchImpl(first);
|
|
27
|
+
|
|
28
|
+
// Read once, up front: a Request body is a stream, and a 307 or 308 sends it again.
|
|
29
|
+
const payload = first.body === null ? null : await first.clone().arrayBuffer();
|
|
30
|
+
let current = first;
|
|
31
|
+
let res = await fetchImpl(first, { redirect: 'manual' });
|
|
32
|
+
for (let hops = 0; ; hops++) {
|
|
33
|
+
if (res.type === 'opaqueredirect') {
|
|
34
|
+
throw new TypeError(`A redirect from ${current.url} was not followed: this platform does not say where it leads. Request the destination directly.`);
|
|
35
|
+
}
|
|
36
|
+
const location = res.headers.get('location');
|
|
37
|
+
if (!REDIRECTS.has(res.status) || location === null) return res;
|
|
38
|
+
if (hops >= MAX_REDIRECTS) throw new TypeError(`Too many redirects from ${first.url}.`);
|
|
39
|
+
await res.body?.cancel();
|
|
40
|
+
|
|
41
|
+
const next = new URL(location, current.url);
|
|
42
|
+
const toGet = (res.status === 303 && current.method !== 'HEAD')
|
|
43
|
+
|| ((res.status === 301 || res.status === 302) && current.method === 'POST');
|
|
44
|
+
const headers = new Headers(current.headers);
|
|
45
|
+
if (toGet) for (const h of BODY_HEADERS) headers.delete(h);
|
|
46
|
+
if (next.origin !== new URL(current.url).origin) for (const h of CREDENTIAL_HEADERS) headers.delete(h);
|
|
47
|
+
|
|
48
|
+
await admit(next);
|
|
49
|
+
current = new Request(next, {
|
|
50
|
+
method: toGet ? 'GET' : current.method,
|
|
51
|
+
headers,
|
|
52
|
+
body: toGet ? null : payload,
|
|
53
|
+
signal: first.signal,
|
|
54
|
+
redirect: 'manual',
|
|
55
|
+
});
|
|
56
|
+
res = await fetchImpl(current);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen, TurnToolSource,
|
|
3
3
|
ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
|
|
4
|
+
ValidateContext, ToolInputError,
|
|
4
5
|
} from './types.js';
|
|
5
6
|
import { foldOntoUserTurn, createMessage } from './session.js';
|
|
6
7
|
import { withUsageSite } from './usage-context.js';
|
|
7
8
|
|
|
8
9
|
const HOOK_ERROR_CREATOR = 'matbot-hooks';
|
|
9
10
|
|
|
11
|
+
// A turn tool is never registered, so `register` never stamps its plugin — and that stamp is what a
|
|
12
|
+
// `ctx.gate` namespace is checked against. The hook that offered it IS stamped, so its tools take that,
|
|
13
|
+
// and a `pluginName` the source wrote itself is dropped: the claim would otherwise be the plugin's own word.
|
|
14
|
+
const ownedBy = (source: TurnToolSource, owner: string | undefined): TurnToolSource => async session =>
|
|
15
|
+
(await source(session)).map(({ pluginName: _, ...tool }) => owner !== undefined ? { ...tool, pluginName: owner } : tool);
|
|
16
|
+
|
|
10
17
|
export class HookRegistry implements HookRegistrar {
|
|
11
18
|
private readonly hooks = new Map<HookPoint, Hook[]>();
|
|
12
19
|
|
|
@@ -156,7 +163,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
156
163
|
if (r.durable && r.durable.length) foldDurable(r.durable);
|
|
157
164
|
if (r.markers && r.markers.length) appendMarkers(r.markers);
|
|
158
165
|
if (r.deferred) deferred.push(r.deferred);
|
|
159
|
-
if (r.tools) tools.push(r.tools);
|
|
166
|
+
if (r.tools) tools.push(ownedBy(r.tools, hook.pluginName));
|
|
160
167
|
if (r.abort) {
|
|
161
168
|
const drained = this.drainFailureMarkers(session);
|
|
162
169
|
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools, abort: r.abort };
|
|
@@ -186,6 +193,24 @@ export class HookRegistry implements HookRegistrar {
|
|
|
186
193
|
return {};
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
// validate asks EVERY hook — no short-circuit — so neither the verdict nor the message depends on which
|
|
197
|
+
// validator happened to load first: refused if any refuses, with every refusal's errors (a field two
|
|
198
|
+
// validators both reject is said once). `[]` from any is "valid"; `undefined` from all is no opinion.
|
|
199
|
+
async runValidate(ctx: Omit<ValidateContext, 'removeHook'>): Promise<ToolInputError[] | undefined> {
|
|
200
|
+
let opinion: ToolInputError[] | undefined;
|
|
201
|
+
const seen = new Set<string>();
|
|
202
|
+
for (const hook of this.on('validate')) {
|
|
203
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('validate', hook) }));
|
|
204
|
+
if (!r) continue;
|
|
205
|
+
opinion ??= [];
|
|
206
|
+
for (const e of r) {
|
|
207
|
+
const key = `${e.path}\0${e.message}`;
|
|
208
|
+
if (!seen.has(key)) { seen.add(key); opinion.push(e); }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return opinion;
|
|
212
|
+
}
|
|
213
|
+
|
|
189
214
|
// toolresult folds the tool result through each hook — a transform pipeline (redaction, truncation);
|
|
190
215
|
// a hook that returns nothing just observes (auditing). Returns the final result.
|
|
191
216
|
async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export type * from './types.js';
|
|
12
|
-
export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT } from './types.js';
|
|
12
|
+
export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT, importPermitted, importableByName, permitted, refused, refusalMessage } from './types.js';
|
|
13
13
|
|
|
14
14
|
export * from './base64.js';
|
|
15
15
|
export * from './bytes.js';
|
|
@@ -17,8 +17,11 @@ export * from './store-query.js';
|
|
|
17
17
|
export * from './errors.js';
|
|
18
18
|
export * from './vault.js';
|
|
19
19
|
export * from './provider-patch.js';
|
|
20
|
+
export * from './provider-models.js';
|
|
20
21
|
export * from './session.js';
|
|
21
22
|
export * from './invoke-tool.js';
|
|
23
|
+
export * from './fetch-origin.js';
|
|
24
|
+
export * from './contract-arms.js';
|
|
22
25
|
|
|
23
26
|
// The plugin half of the machine: MatbotPlugin/MatbotServices/MatbotRuntime, the mount-table *consumer*
|
|
24
27
|
// contract, and the API version a plugin declares. Host assembly is in ./host-machine.ts.
|
package/src/invoke-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MatbotMachine, MatbotPlugin } from './plugin.js';
|
|
2
|
-
import type { ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField, PermissionGate } from './types.js';
|
|
2
|
+
import type { Tool, ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField, PermissionGate } from './types.js';
|
|
3
3
|
import { askPermissionGate } from './permission-gate.js';
|
|
4
4
|
|
|
5
5
|
/** The host's plugin hot-load ops, as both the runner and {@link invokeTool} hold them. */
|
|
@@ -33,21 +33,56 @@ export function bindPluginOps(host: PluginOps, prompt: PromptFn): Pick<ToolConte
|
|
|
33
33
|
* stops a plugin addressing a gate it does not own — tool-name collision on `register` is already
|
|
34
34
|
* resolved, so the name is not something a second plugin can quietly claim.
|
|
35
35
|
*
|
|
36
|
+
* A request may name a `namespace` in place of the tool's name — for a tool whose name is chosen at RUN
|
|
37
|
+
* TIME, which is a function-tools function — and it is honoured only if `tools` resolves it to a tool
|
|
38
|
+
* stamped with the same plugin as this one. Both stamps are the host's (`register`, and the `screen` hook
|
|
39
|
+
* that offered a turn tool), so a plugin can name only a gate it already owns. Without `tools`, or for a
|
|
40
|
+
* tool known only by a bare name, there is nothing to check against, so a namespace is refused.
|
|
41
|
+
*
|
|
36
42
|
* `ask` is the RAW per-turn prompt, never a stand-in that answers with a field's default: `undefined`
|
|
37
43
|
* is how "no human is reachable" reaches the policy, and a substitute would make that undecidable.
|
|
38
44
|
* `gate` is the registered `PermissionGate`; it is a non-optional service, but a hand-assembled
|
|
39
45
|
* machine (a test, a minimal embedder) may still have none, so the asking default stands in.
|
|
40
46
|
*/
|
|
41
47
|
export function bindGate(
|
|
42
|
-
gate:
|
|
43
|
-
|
|
44
|
-
ask:
|
|
48
|
+
gate: PermissionGate | undefined,
|
|
49
|
+
tool: string | Pick<Tool, 'name' | 'pluginName'>,
|
|
50
|
+
ask: PromptFn | undefined,
|
|
51
|
+
tools?: { resolve(name: string): Pick<Tool, 'pluginName'> | null },
|
|
45
52
|
): Pick<ToolContext, 'gate'> {
|
|
53
|
+
const name = typeof tool === 'string' ? tool : tool.name;
|
|
54
|
+
const owner = typeof tool === 'string' ? undefined : tool.pluginName;
|
|
46
55
|
return {
|
|
47
|
-
gate:
|
|
56
|
+
gate: async ({ namespace, ...req }) => {
|
|
57
|
+
if (namespace !== undefined && namespace !== name
|
|
58
|
+
&& (owner === undefined || tools?.resolve(namespace)?.pluginName !== owner)) {
|
|
59
|
+
throw new Error(`Tool "${name}" cannot ask permission under "${namespace}": a gate namespace must be a tool registered by the same plugin.`);
|
|
60
|
+
}
|
|
61
|
+
const qualified = { ...req, gate: `${namespace ?? name}.${req.gate}` };
|
|
62
|
+
const decision = await (gate ?? askPermissionGate).decide(qualified, ask);
|
|
63
|
+
if (!decision.allowed && decision.source === 'fallback') reportUnattended(name, qualified.gate, qualified.subject);
|
|
64
|
+
return decision;
|
|
65
|
+
},
|
|
48
66
|
};
|
|
49
67
|
}
|
|
50
68
|
|
|
69
|
+
// When nobody can be asked (a background job, a trigger, `POST /tools/:name`), a gate refuses, and the model
|
|
70
|
+
// is told so. Nobody else would be: the operation simply did not happen, in a process nobody is watching. So
|
|
71
|
+
// it is logged, naming what a standing answer would have to say to allow it. At error level, because a
|
|
72
|
+
// background job's process keeps only errors (its output is its result); and at most once an hour per gate
|
|
73
|
+
// and subject, because a dashboard polling a gated tool would otherwise log every poll.
|
|
74
|
+
const UNATTENDED_REPORT_MS = 60 * 60 * 1000;
|
|
75
|
+
const unattendedReported = new Map<string, number>();
|
|
76
|
+
function reportUnattended(tool: string, gate: string, subject: string): void {
|
|
77
|
+
const key = `${gate}\0${subject}`;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (now - (unattendedReported.get(key) ?? -Infinity) < UNATTENDED_REPORT_MS) return;
|
|
80
|
+
unattendedReported.set(key, now);
|
|
81
|
+
console.error(`[gate] Refused ${gate} for "${subject}" (tool ${tool}): nobody was there to ask. ` +
|
|
82
|
+
`To allow it unattended, give a standing answer for that gate and subject (e.g. answer "Always" ` +
|
|
83
|
+
`when it is next asked in the UI, or configure the permission policy).`);
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
/**
|
|
52
87
|
* Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
|
|
53
88
|
* forwarding a call to another tool should pass its own {@link ToolContext} straight through as
|
|
@@ -97,7 +132,7 @@ export function invokeTool<K extends string, const P>(
|
|
|
97
132
|
// The raw `opts.prompt`, not the rejecting stand-in above: a gate must be able to tell "nobody is
|
|
98
133
|
// here" from "a human answered", and `POST /tools/:name` reaching a privileged tool is exactly the
|
|
99
134
|
// non-interactive case the request's `fallback` exists to answer.
|
|
100
|
-
...bindGate(machine.PermissionGate,
|
|
135
|
+
...bindGate(machine.PermissionGate, tool, opts.prompt, machine.tools),
|
|
101
136
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
102
137
|
...(opts.turnTools !== undefined ? { turnTools: opts.turnTools } : {}),
|
|
103
138
|
...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CONFIRM_NO, CONFIRM_YES } from './types/messages.js';
|
|
2
2
|
import type { FormField } from './types/messages.js';
|
|
3
|
-
import type
|
|
3
|
+
import { permitted, refused, type PermissionGate } from './types/permission.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The bare asking `PermissionGate`: ask through the channel in scope, and answer `req.fallback` when
|
|
@@ -17,8 +17,11 @@ import type { PermissionGate } from './types/permission.js';
|
|
|
17
17
|
*/
|
|
18
18
|
export const askPermissionGate: PermissionGate = {
|
|
19
19
|
async decide(req, ask) {
|
|
20
|
-
|
|
20
|
+
// Two layers, so two sources: nobody to ask is `fallback`, and anything a human answered — a no
|
|
21
|
+
// included — is `user`.
|
|
22
|
+
if (ask === undefined) return req.fallback ? permitted('fallback') : refused('fallback');
|
|
21
23
|
const field: FormField = { name: 'confirm', label: req.label, type: 'confirm', default: CONFIRM_NO };
|
|
22
|
-
|
|
24
|
+
const yes = (await ask(field)).trim().toLowerCase() === CONFIRM_YES;
|
|
25
|
+
return yes ? permitted('user') : refused('user');
|
|
23
26
|
},
|
|
24
27
|
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ProviderAdapter, ProviderConfig } from './types.js';
|
|
2
|
+
import type { Vault } from './types/vault.js';
|
|
3
|
+
|
|
4
|
+
export const MODEL_LIST_TIMEOUT_MS = 8_000;
|
|
5
|
+
|
|
6
|
+
export type ModelCheck =
|
|
7
|
+
| { kind: 'listed' }
|
|
8
|
+
| { kind: 'unlisted'; count: number; closest: string[] }
|
|
9
|
+
/** Nothing could be compared: no `models`, a missing key, a refusal, a timeout, an empty list. */
|
|
10
|
+
| { kind: 'unchecked'; reason: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `model` is one the endpoint lists. Not exact equality, because an endpoint accepts more names
|
|
14
|
+
* than it lists: Anthropic an undated alias of a dated id (`claude-sonnet-4-5` for
|
|
15
|
+
* `claude-sonnet-4-5-20250929`) and a `-latest` one, Ollama a bare name for its `:latest` tag. Gemini's
|
|
16
|
+
* lists say `models/<id>`, which either spelling of the model matches.
|
|
17
|
+
*/
|
|
18
|
+
export function modelListed(model: string, ids: readonly string[]): boolean {
|
|
19
|
+
const m = model.replace(/^models\//, '');
|
|
20
|
+
return ids.some(raw => {
|
|
21
|
+
const id = raw.replace(/^models\//, '');
|
|
22
|
+
return id === m || id === `${m}:latest`
|
|
23
|
+
|| (id.startsWith(`${m}-`) && /^\d{8}$/.test(id.slice(m.length + 1)))
|
|
24
|
+
|| (m.endsWith('-latest') && id.startsWith(m.slice(0, -'latest'.length)));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function editDistance(a: string, b: string): number {
|
|
29
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
30
|
+
for (let i = 1; i <= a.length; i++) {
|
|
31
|
+
const row = [i];
|
|
32
|
+
for (let j = 1; j <= b.length; j++) {
|
|
33
|
+
row[j] = Math.min(prev[j]! + 1, row[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
34
|
+
}
|
|
35
|
+
prev = row;
|
|
36
|
+
}
|
|
37
|
+
return prev[b.length]!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The ids nearest `id`, best first. Shared name segments (family, size, quantisation) find a model's
|
|
42
|
+
* siblings from a partial name (`qwen3-4b`, `sonnet-4-6`); a spelling within two edits of an id comes
|
|
43
|
+
* ahead of them all, being what a typo looks like.
|
|
44
|
+
*/
|
|
45
|
+
export function closestIds(id: string, ids: readonly string[], n = 5): string[] {
|
|
46
|
+
const parts = (s: string): string[] => s.toLowerCase().split(/[-_.:/\s]+/).filter(Boolean);
|
|
47
|
+
const want = parts(id);
|
|
48
|
+
return ids
|
|
49
|
+
.map(candidate => {
|
|
50
|
+
const have = parts(candidate);
|
|
51
|
+
const score = want.filter(w => have.some(h => h === w)).length * 2
|
|
52
|
+
+ want.filter(w => have.some(h => h !== w && (h.includes(w) || w.includes(h)))).length;
|
|
53
|
+
return { id: candidate, score, dist: editDistance(id.toLowerCase(), candidate.toLowerCase()) };
|
|
54
|
+
})
|
|
55
|
+
.filter(x => x.score > 0 || x.dist <= 2)
|
|
56
|
+
.sort((a, b) => Number(a.dist > 2) - Number(b.dist > 2) || b.score - a.score || a.dist - b.dist || a.id.localeCompare(b.id))
|
|
57
|
+
.slice(0, n)
|
|
58
|
+
.map(x => x.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Asks the endpoint which models it serves and compares `config.model` against them, so a typo is caught
|
|
63
|
+
* where it is made rather than as a 404 on the next turn. Listing SENDS the profile's credential, so a
|
|
64
|
+
* caller asks only once that credential may go to `config.endpoint` — the same exposure the profile's
|
|
65
|
+
* own first turn has.
|
|
66
|
+
*
|
|
67
|
+
* Never throws, and `unchecked` is not a failure: most endpoints that cannot list (a gateway, a key not
|
|
68
|
+
* yet stored, a server with no `/models`) still serve the model fine.
|
|
69
|
+
*/
|
|
70
|
+
export async function checkProviderModel(
|
|
71
|
+
adapter: Pick<ProviderAdapter, 'models'>,
|
|
72
|
+
config: ProviderConfig,
|
|
73
|
+
vault: Pick<Vault, 'resolve'>,
|
|
74
|
+
timeoutMs: number = MODEL_LIST_TIMEOUT_MS,
|
|
75
|
+
): Promise<ModelCheck> {
|
|
76
|
+
if (adapter.models === undefined) return { kind: 'unchecked', reason: 'the adapter cannot list its models' };
|
|
77
|
+
let resolved: ProviderConfig;
|
|
78
|
+
try {
|
|
79
|
+
const credentials: Record<string, string> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(config.credentials ?? {})) credentials[k] = await vault.resolve(v);
|
|
81
|
+
resolved = {
|
|
82
|
+
...config,
|
|
83
|
+
...(config.credentials !== undefined ? { credentials } : {}),
|
|
84
|
+
...(config.endpoint !== undefined ? { endpoint: await vault.resolve(config.endpoint) } : {}),
|
|
85
|
+
};
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { kind: 'unchecked', reason: e instanceof Error ? e.message : String(e) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
91
|
+
let ids: readonly string[];
|
|
92
|
+
try {
|
|
93
|
+
// Raced as well as signalled: an adapter may ignore the signal, and a hung list must not hold up the tool.
|
|
94
|
+
ids = await Promise.race([
|
|
95
|
+
adapter.models(resolved, signal),
|
|
96
|
+
new Promise<never>((_, reject) => signal.addEventListener('abort', () => reject(new Error(`no answer within ${timeoutMs / 1000}s`)), { once: true })),
|
|
97
|
+
]);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// An endpoint's error body is often pretty-printed JSON; the reason is shown on one line.
|
|
100
|
+
const why = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, ' ');
|
|
101
|
+
return { kind: 'unchecked', reason: why.length > 200 ? `${why.slice(0, 200)}…` : why };
|
|
102
|
+
}
|
|
103
|
+
if (ids.length === 0) return { kind: 'unchecked', reason: 'the endpoint listed no models' };
|
|
104
|
+
if (modelListed(config.model, ids)) return { kind: 'listed' };
|
|
105
|
+
return { kind: 'unlisted', count: ids.length, closest: closestIds(config.model, ids) };
|
|
106
|
+
}
|
package/src/provider-patch.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ProviderConfig } from './types.js';
|
|
2
2
|
import type { ProviderPatch } from './types/builtin-tools.js';
|
|
3
|
+
import type { VaultSpec } from './types/vault.js';
|
|
4
|
+
|
|
5
|
+
/** The vault key a profile's own credential is stored under by default: `MATBOT_API_KEY_<PROFILE>`. */
|
|
6
|
+
export function credentialKeyName(profile: string): string {
|
|
7
|
+
return `MATBOT_API_KEY_${profile.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* The `provider update` policy, written once so every implementer of it behaves identically — the same
|
|
@@ -12,10 +18,16 @@ import type { ProviderPatch } from './types/builtin-tools.js';
|
|
|
12
18
|
* which is what makes a read-modify-write something a caller can actually carry out. (Contrast
|
|
13
19
|
* `default_settings`, which merges per key: there the keys are matbot's own.)
|
|
14
20
|
*
|
|
15
|
-
* `credentials` and `module` are absent from `ProviderPatch
|
|
16
|
-
*
|
|
21
|
+
* `credentials` and `module` are absent from `ProviderPatch`; see that type for why neither belongs in an
|
|
22
|
+
* update. `module` rides through untouched. `credentials` does too — unless the endpoint now names another
|
|
23
|
+
* ORIGIN (scheme, host and port; clearing it back to the adapter's default counts), because the credential
|
|
24
|
+
* is sent wherever the endpoint points: an endpoint change was a way to post the key to any host at all,
|
|
25
|
+
* with the update's own prompt never mentioning it. So the profile is pointed at fresh vault keys instead,
|
|
26
|
+
* none of which exists yet, and whoever means it to have a credential there supplies one out of band
|
|
27
|
+
* (`plugin store-key`). The old keys stay in the vault, since another profile may share them.
|
|
28
|
+
* {@link movedCredentialKeys} names the new ones.
|
|
17
29
|
*/
|
|
18
|
-
export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch): ProviderConfig {
|
|
30
|
+
export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch, vault: Pick<VaultSpec, 'hasKey'>): ProviderConfig {
|
|
19
31
|
const next: ProviderConfig = { ...cur };
|
|
20
32
|
// Spelled out per field rather than looped: `exactOptionalPropertyTypes` makes "assign, or delete if
|
|
21
33
|
// null" a different statement for each type, and a loop only reaches them through a cast that would
|
|
@@ -24,9 +36,34 @@ export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch): P
|
|
|
24
36
|
if (patch.endpoint !== undefined) { if (patch.endpoint === null) delete next.endpoint; else next.endpoint = patch.endpoint; }
|
|
25
37
|
if (patch.parameters !== undefined) { if (patch.parameters === null) delete next.parameters; else next.parameters = patch.parameters; }
|
|
26
38
|
if (patch.maxRounds !== undefined) { if (patch.maxRounds === null) delete next.maxRounds; else next.maxRounds = patch.maxRounds; }
|
|
39
|
+
if (cur.credentials !== undefined && originOf(cur.endpoint) !== originOf(next.endpoint)) {
|
|
40
|
+
const base = credentialKeyName(cur.name);
|
|
41
|
+
const taken = new Set<string>();
|
|
42
|
+
const fresh: Record<string, string> = {};
|
|
43
|
+
for (const field of Object.keys(cur.credentials)) {
|
|
44
|
+
const stem = field === 'apiKey' ? base : `${base}_${field.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
45
|
+
let key = stem;
|
|
46
|
+
for (let n = 2; vault.hasKey(key) || taken.has(key); n++) key = `${stem}_${n}`;
|
|
47
|
+
taken.add(key);
|
|
48
|
+
fresh[field] = `\${${key}}`;
|
|
49
|
+
}
|
|
50
|
+
next.credentials = fresh;
|
|
51
|
+
}
|
|
27
52
|
return next;
|
|
28
53
|
}
|
|
29
54
|
|
|
55
|
+
/** The vault keys a patched profile now reads its credential from, where {@link applyProviderPatch} took
|
|
56
|
+
* it off because the endpoint moved origin; empty when it did not. */
|
|
57
|
+
export function movedCredentialKeys(cur: ProviderConfig, next: ProviderConfig): readonly string[] {
|
|
58
|
+
if (cur.credentials === next.credentials || next.credentials === undefined) return [];
|
|
59
|
+
return Object.values(next.credentials).map(v => v.slice(2, -1));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// An endpoint that is not a URL is compared as written: nothing can say it is the same place.
|
|
63
|
+
function originOf(endpoint: string | undefined): string | undefined {
|
|
64
|
+
return endpoint !== undefined && URL.canParse(endpoint) ? new URL(endpoint).origin : endpoint;
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
/** The fields a patch actually changes, in a stable order — for a confirmation prompt or a result message. */
|
|
31
68
|
export function patchedFields(patch: ProviderPatch): readonly ('model' | 'endpoint' | 'parameters' | 'maxRounds')[] {
|
|
32
69
|
return (['model', 'endpoint', 'parameters', 'maxRounds'] as const).filter(f => patch[f] !== undefined);
|
|
@@ -184,7 +184,8 @@ export interface ProviderActionResult {
|
|
|
184
184
|
* `provider list` does not report back (it projects to `hasCredentials`, and a secret must not ride the
|
|
185
185
|
* transcript) — so a caller cannot read-modify-write it, and an `update` that accepted it would have to
|
|
186
186
|
* guess between rewriting a `${NAME}` reference and replacing a literal. Credentials are
|
|
187
|
-
* `plugin store-key`'s, which addresses the vault name directly.
|
|
187
|
+
* `plugin store-key`'s, which addresses the vault name directly. (An `endpoint` moved to another origin does
|
|
188
|
+
* change them, by policy — see `applyProviderPatch`.) And **`module`**, because changing the
|
|
188
189
|
* adapter re-opens every resolution question `add` answers (is it loaded, does it resolve by package
|
|
189
190
|
* name, is it even a provider adapter); `remove` + `add` is the honest route for that.
|
|
190
191
|
*
|
package/src/types/events.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type TurnEvent =
|
|
|
17
17
|
| { type: 'thinking'; delta: string; traceId: string }
|
|
18
18
|
| { type: 'system-context'; text: string; traceId: string }
|
|
19
19
|
| ({ type: 'usage'; traceId: string } & Usage)
|
|
20
|
+
// The provider's own pre-answer work (a local model downloading or loading) — its `progress`
|
|
21
|
+
// CompletionEvent, forwarded. Live-only; superseded by whatever the turn does next.
|
|
22
|
+
| { type: 'progress'; pct: number; message?: string; traceId: string }
|
|
20
23
|
|
|
21
24
|
// ── tool execution ──
|
|
22
25
|
| { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
|
package/src/types/hooks.ts
CHANGED
|
@@ -48,8 +48,12 @@ export interface RunConfig {
|
|
|
48
48
|
* ephemeral context — supersede rather than follow), and/or append durable `markers`
|
|
49
49
|
* to the committed session (LLM-invisible annotations — the second durable-write point
|
|
50
50
|
* after `screen`, safe because it too fires once per turn).
|
|
51
|
+
* validate the EXECUTOR, before every call through every door — the runner, `POST /tools/:name`,
|
|
52
|
+
* `invokeTool` — and `tools.validate`. Not a runner channel, so it carries no session.
|
|
53
|
+
* Every hook runs; the call is refused if ANY returns errors. `undefined` is no opinion,
|
|
54
|
+
* never "valid". See {@link ValidateContext}.
|
|
51
55
|
*/
|
|
52
|
-
export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup';
|
|
56
|
+
export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup' | 'validate';
|
|
53
57
|
|
|
54
58
|
export interface ScreenContext {
|
|
55
59
|
session: Session;
|
|
@@ -181,6 +185,41 @@ export interface ToolResultContext {
|
|
|
181
185
|
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
182
186
|
removeHook(): void;
|
|
183
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* One rejected field. `path` is dotted, as a caller would WRITE the access (`.items[0].name`) rather
|
|
190
|
+
* than as JSON Pointer — the reader is either a model repairing its own call or a developer looking at
|
|
191
|
+
* a 422, and both write dots. `.` alone is the whole value.
|
|
192
|
+
*
|
|
193
|
+
* `value` is what was actually supplied there, and is what makes the difference between a message a
|
|
194
|
+
* model can act on and one it has to guess at. Optional because a validator may not have it, and
|
|
195
|
+
* `undefined` specifically means ABSENT: JSON carries no undefined, so there is nothing to show for a
|
|
196
|
+
* missing property beyond saying it is missing.
|
|
197
|
+
*/
|
|
198
|
+
export interface ToolInputError {
|
|
199
|
+
path: string;
|
|
200
|
+
message: string;
|
|
201
|
+
value?: unknown;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A tool call about to execute, for a validator to judge — `json-validation` against the tool's
|
|
206
|
+
* `inputSchema`, `ts-validation` against its `ToolContract` params type. A hook rather than a service
|
|
207
|
+
* so several co-exist with no reference to each other: each is consulted, a call is refused if any
|
|
208
|
+
* refuses it, and unloading one removes only its own opinion. It was a single-valued service key, which
|
|
209
|
+
* forced the second validator to capture the first and delegate to it — so the pair depended on load
|
|
210
|
+
* order, and on each other.
|
|
211
|
+
*
|
|
212
|
+
* Return errors to refuse, `[]` to say the input is valid, or nothing for NO OPINION (no contract for
|
|
213
|
+
* this tool, or one that cannot be honestly validated). A throw is no opinion too, logged.
|
|
214
|
+
*/
|
|
215
|
+
export interface ValidateContext {
|
|
216
|
+
readonly tool: string;
|
|
217
|
+
readonly parameters: unknown;
|
|
218
|
+
signal: AbortSignal;
|
|
219
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
220
|
+
removeHook(): void;
|
|
221
|
+
}
|
|
222
|
+
|
|
184
223
|
// The toolresult hook returns `{ result }` to replace the tool's result, or nothing to leave it
|
|
185
224
|
// (and just observe) — a trivial single-field return, inlined in the Hook union like `contribute`'s.
|
|
186
225
|
|
|
@@ -235,4 +274,5 @@ export type Hook =
|
|
|
235
274
|
| { on: 'contribute'; priority?: number; pluginName?: string; handler(ctx: ContributeContext): Message[] | void | Promise<Message[] | void> }
|
|
236
275
|
| { on: 'toolcall'; priority?: number; pluginName?: string; handler(ctx: ToolCallContext): ToolCallResult | void | Promise<ToolCallResult | void> }
|
|
237
276
|
| { on: 'toolresult'; priority?: number; pluginName?: string; handler(ctx: ToolResultContext): { result: unknown } | void | Promise<{ result: unknown } | void> }
|
|
238
|
-
| { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> }
|
|
277
|
+
| { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> }
|
|
278
|
+
| { on: 'validate'; priority?: number; pluginName?: string; handler(ctx: ValidateContext): ToolInputError[] | undefined | void | Promise<ToolInputError[] | undefined | void> };
|
package/src/types/permission.ts
CHANGED
|
@@ -26,6 +26,87 @@ export interface PermissionRequest {
|
|
|
26
26
|
/** What this site does when nothing can be asked and nothing decides — today's non-interactive
|
|
27
27
|
* behaviour, stated once per site instead of implied at each. */
|
|
28
28
|
fallback: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The standing answers this act may be remembered as, NARROWEST FIRST — what a policy offers beside
|
|
31
|
+
* "just this once", each as the subject it would store and the prose a human reads. A policy consults
|
|
32
|
+
* {@link subject} and then each of these in order, and may store whichever was chosen; absent, the
|
|
33
|
+
* only standing answer is the exact subject, labelled by the policy.
|
|
34
|
+
*
|
|
35
|
+
* The call site supplies the SUBJECTS because only it knows how its own generalise: a module specifier
|
|
36
|
+
* widens by protocol or by directory, a plugin specifier by registry scope, and neither rule is one a
|
|
37
|
+
* policy could derive without knowing the gate. Which is the point — a policy does **set membership**
|
|
38
|
+
* over strings it never parses, so no gate id is hardcoded in it and a gate this build never compiled
|
|
39
|
+
* against generalises the same way.
|
|
40
|
+
*
|
|
41
|
+
* It supplies the LABELS for the same reason it supplies {@link label}: a subject is a key, chosen to
|
|
42
|
+
* be stable and unambiguous, and keys make terrible prose. `#execute node:fs/` is a correct key and an
|
|
43
|
+
* unusable question. A policy rendering `Always allow "<subject>"` can only ever show the key, because
|
|
44
|
+
* saying it in English needs exactly the knowledge the subject was built to avoid requiring.
|
|
45
|
+
*
|
|
46
|
+
* A policy must never store a subject that was not offered here: that is what keeps this from widening
|
|
47
|
+
* the memory semantics of every other gate.
|
|
48
|
+
*/
|
|
49
|
+
standing?: readonly { readonly subject: string; readonly label: string }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which test in the cascade produced an answer — not *who* answered, but which of the layers a policy
|
|
54
|
+
* consults got there first: a human at the prompt, a standing answer out of configuration, or the call
|
|
55
|
+
* site's own `fallback` because nothing else could decide. matbot's own three are named here; the type
|
|
56
|
+
* stays **open**, because a replacement policy has layers of its own (`'user-stored-preference'`, an
|
|
57
|
+
* org directory, a rule table) and a closed union would mean a policy could not report truthfully
|
|
58
|
+
* without a plugin-api change. Treat an unrecognised source exactly as the prose helper does: report it.
|
|
59
|
+
*/
|
|
60
|
+
export type PermissionSource =
|
|
61
|
+
/** A human was asked and answered. */
|
|
62
|
+
| 'user'
|
|
63
|
+
/** A standing answer in force — an installation's configured floor or a remembered one; the settings
|
|
64
|
+
* layering that holds them makes the two indistinguishable by construction, so they share a source. */
|
|
65
|
+
| 'configuration'
|
|
66
|
+
/** Nothing decided and nobody was reachable: {@link PermissionRequest.fallback} stands. */
|
|
67
|
+
| 'fallback'
|
|
68
|
+
| (string & {});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The answer: whether the act may proceed, and which layer said so.
|
|
72
|
+
*
|
|
73
|
+
* `source` exists for the REPORT rather than the control flow — a refused tool call that says only
|
|
74
|
+
* "Cancelled." reads to a model as a malfunction, so it hunts for a workaround or a bug to fix. A
|
|
75
|
+
* denial that names its origin is a decision it can relay instead (see {@link refusalMessage}).
|
|
76
|
+
*
|
|
77
|
+
* Nothing branches on `source`, and a gate must not be given one to branch on: the outcome is
|
|
78
|
+
* `allowed`, and a call site that treated `'fallback'` differently from a human's "no" would be
|
|
79
|
+
* reintroducing the policy the gate exists to hold.
|
|
80
|
+
*/
|
|
81
|
+
export interface PermissionDecision {
|
|
82
|
+
allowed: boolean;
|
|
83
|
+
source: PermissionSource;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The allow/deny pair, spelled once. */
|
|
87
|
+
export const permitted = (source: PermissionSource): PermissionDecision => ({ allowed: true, source });
|
|
88
|
+
export const refused = (source: PermissionSource): PermissionDecision => ({ allowed: false, source });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a refused call site tells the caller — including the model, which is the reader this exists for.
|
|
92
|
+
* A bare "Cancelled." is indistinguishable from a fault, and a model that reads a decision as a fault
|
|
93
|
+
* goes looking for the fault: it retries under another spelling, probes for a bug, or reaches for a
|
|
94
|
+
* route around the gate. So the prose names the layer that refused and says, for the two sources where
|
|
95
|
+
* it is true, that there is nothing here to work around.
|
|
96
|
+
*
|
|
97
|
+
* `act` names the operation in the caller's own words when it has them ("Install plugin \"@x/foo\"");
|
|
98
|
+
* the sentence stands without it. An unrecognised source is reported as itself rather than guessed at —
|
|
99
|
+
* a policy's own layer name is prose a human chose, and saying it is more use than "refused".
|
|
100
|
+
*/
|
|
101
|
+
export function refusalMessage(decision: PermissionDecision, act?: string): string {
|
|
102
|
+
const what = act !== undefined ? `${act}: cancelled` : 'Cancelled';
|
|
103
|
+
const stop = 'This is a decision, not a fault — do not retry it or look for a way around it.';
|
|
104
|
+
switch (decision.source) {
|
|
105
|
+
case 'user': return `${what} — the user declined. ${stop}`;
|
|
106
|
+
case 'configuration': return `${what} — the installation's permission policy does not allow it. ${stop}`;
|
|
107
|
+
case 'fallback': return `${what} — permission was required and nobody could be asked, so the non-interactive default applied. Nothing is broken; it needs a user present, or a standing answer in the installation's policy.`;
|
|
108
|
+
default: return `${what} — refused by the permission policy (${decision.source}). ${stop}`;
|
|
109
|
+
}
|
|
29
110
|
}
|
|
30
111
|
|
|
31
112
|
/**
|
|
@@ -39,8 +120,8 @@ export interface PermissionRequest {
|
|
|
39
120
|
* means **no human is reachable**; a policy that wants to behave differently then simply checks it.
|
|
40
121
|
*
|
|
41
122
|
* A gate that wants "my rules, else the previous behaviour" captures the gate it displaces and
|
|
42
|
-
* delegates to it
|
|
123
|
+
* delegates to it, so a pair composes in either load order.
|
|
43
124
|
*/
|
|
44
125
|
export interface PermissionGate {
|
|
45
|
-
decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<
|
|
126
|
+
decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<PermissionDecision>;
|
|
46
127
|
}
|
package/src/types/provider.ts
CHANGED
|
@@ -217,6 +217,14 @@ export type CompletionEvent =
|
|
|
217
217
|
* a larger budget) is a `followup` hook's business, not the harness's.
|
|
218
218
|
*/
|
|
219
219
|
| { type: 'truncated'; reason: 'max-tokens' | 'stream-end'; raw?: string }
|
|
220
|
+
/**
|
|
221
|
+
* Progress on work the provider must finish before it can answer at all — an on-device model
|
|
222
|
+
* downloading or loading. `pct` is 0–100, as a tool's `progress` event. Live-only: forwarded as the
|
|
223
|
+
* turn's `progress` event and never persisted, being a fact about the moment rather than the reply.
|
|
224
|
+
* Not a substitute for `thinking`, which is the model's own output; putting a status line there made
|
|
225
|
+
* it read as something the model thought.
|
|
226
|
+
*/
|
|
227
|
+
| { type: 'progress'; pct: number; message?: string }
|
|
220
228
|
| ({ type: 'usage' } & Usage)
|
|
221
229
|
| { type: 'done' };
|
|
222
230
|
|
|
@@ -256,4 +264,11 @@ export interface ProviderAdapter {
|
|
|
256
264
|
signal: AbortSignal
|
|
257
265
|
): AsyncIterable<CompletionEvent>;
|
|
258
266
|
health(): Promise<HealthStatus>;
|
|
267
|
+
/**
|
|
268
|
+
* The model ids this adapter can run under `config`, for a person choosing one (the setup wizards) and
|
|
269
|
+
* for `checkProviderModel`, which catches a mistyped one. Absent where the adapter cannot enumerate
|
|
270
|
+
* them, and a rejection means the same: the caller asks for a model name instead, or accepts it
|
|
271
|
+
* unchecked. `config` carries resolved credentials, which a remote list is sent with.
|
|
272
|
+
*/
|
|
273
|
+
models?(config: ProviderConfig, signal?: AbortSignal): Promise<string[]>;
|
|
259
274
|
}
|
package/src/types/registries.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Hook } from './hooks.js';
|
|
1
|
+
import type { Hook, ToolInputError } from './hooks.js';
|
|
2
2
|
import type { Session } from './messages.js';
|
|
3
3
|
import type { ProviderConfig } from './provider.js';
|
|
4
4
|
import type { Tool } from './tools.js';
|
|
@@ -34,6 +34,11 @@ export interface ToolRegistry {
|
|
|
34
34
|
resolve(name: string): Tool | null;
|
|
35
35
|
list(): readonly Tool[];
|
|
36
36
|
removeByPlugin(pluginName: string): void;
|
|
37
|
+
/** Judge `parameters` for the tool `name` exactly as its executor will before running it: every
|
|
38
|
+
* `validate` hook. Errors refuse, `[]` is valid, `undefined` means nothing had an opinion. For a
|
|
39
|
+
* caller that stores a call to make later (a trigger's `invoke`), so it can be refused while its
|
|
40
|
+
* author is present. Optional: a registry stood up by hand need not validate. */
|
|
41
|
+
validate?(name: string, parameters: unknown): Promise<ToolInputError[] | undefined>;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
|
package/src/types/tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { FileHandle, FileStore } from './files.js';
|
|
|
2
2
|
import type { FormField, MessageContent, Session } from './messages.js';
|
|
3
3
|
import type { MatbotPlugin } from '../plugin.js';
|
|
4
4
|
import type { JSONSchema } from './primitives.js';
|
|
5
|
-
import type { PermissionRequest } from './permission.js';
|
|
5
|
+
import type { PermissionDecision, PermissionRequest } from './permission.js';
|
|
6
6
|
import type { Vault } from './vault.js';
|
|
7
7
|
import type { SessionAppender } from './session-append.js';
|
|
8
8
|
|
|
@@ -230,15 +230,25 @@ export interface ToolContext {
|
|
|
230
230
|
prompt: PromptFn;
|
|
231
231
|
/**
|
|
232
232
|
* Ask permission for a privileged operation — install a plugin, remove a provider profile, add an
|
|
233
|
-
* MCP server.
|
|
234
|
-
*
|
|
233
|
+
* MCP server. The call site declares *that* acceptance is needed; the registered
|
|
234
|
+
* {@link PermissionGate} decides *how* it is obtained (the default asks the user).
|
|
235
235
|
*
|
|
236
236
|
* `gate` is the SUFFIX (`'add'`): the host qualifies it with this tool's registered name, so one
|
|
237
237
|
* policy covers both runtimes' implementations of a tool (node's `plugin` and the browser's) and a
|
|
238
238
|
* plugin cannot address a gate it does not own. `fallback` is what happens when no human is
|
|
239
239
|
* reachable and no policy decides.
|
|
240
|
+
*
|
|
241
|
+
* `namespace` replaces the tool's name as the qualifier, for a tool whose NAME is chosen at run time
|
|
242
|
+
* (a `function-tools` function asks under `tool_function`): qualified by a model-chosen name, every
|
|
243
|
+
* definition would mint its own gate id, so a standing answer filed under one could never cover
|
|
244
|
+
* another. It must name a tool registered by the SAME plugin as this one — the host checks, against
|
|
245
|
+
* the plugin it stamped on both — so it widens nothing: a plugin still cannot address another's gate.
|
|
246
|
+
*
|
|
247
|
+
* The outcome is `allowed`; `source` names the layer that answered, for the refusal to REPORT (see
|
|
248
|
+
* {@link refusalMessage}) — never to branch on. NOTE the shape: this resolves to an object, so
|
|
249
|
+
* `if (!await ctx.gate(…))` is always false. Test `.allowed`.
|
|
240
250
|
*/
|
|
241
|
-
gate(req: PermissionRequest): Promise<
|
|
251
|
+
gate(req: PermissionRequest & { namespace?: string }): Promise<PermissionDecision>;
|
|
242
252
|
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
243
253
|
* `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
|
|
244
254
|
* re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
|
|
@@ -304,6 +314,12 @@ export interface Tool<R = unknown> {
|
|
|
304
314
|
* its contract as a `ToolContracts` augmentation instead and omits this. Foreign tools (e.g. MCP proxies)
|
|
305
315
|
* that have neither fall back to the loose `inputSchema`. */
|
|
306
316
|
toolContract?: string;
|
|
317
|
+
/** Declarations (`interface`, `type`) that {@link toolContract} names and that exist nowhere else — a
|
|
318
|
+
* function package's own types, which its exports' contracts refer to by name. In scope for this tool's
|
|
319
|
+
* contract only: a consumer declares them in a namespace of their own beside the contract
|
|
320
|
+
* (`contractArms`), so two tools' types of one name never meet. Absent ⇒ the contract names only
|
|
321
|
+
* plugin-api types and TypeScript's own. */
|
|
322
|
+
toolContractTypes?: string;
|
|
307
323
|
executor: ToolExecutor<R>;
|
|
308
324
|
pluginName?: string;
|
|
309
325
|
}
|
package/src/types/typescript.ts
CHANGED
|
@@ -18,25 +18,194 @@ export interface TypeScriptStripper {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Optional, host-supplied:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
|
|
21
|
+
* Optional, host-supplied: turns model-authored TypeScript into something runnable, and is the
|
|
22
|
+
* environment it runs in. How it gets there — strip and compile, transform, interpret — is its own
|
|
23
|
+
* business; the rest of this interface is one value it is GIVEN per call, and two facts about its
|
|
24
|
+
* environment that nothing else can know.
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Its first job is bounding a body's SYNCHRONOUS execution. Model-authored code runs on the one event
|
|
27
|
+
* loop every session, frontend and schedule shares, so a loop that never awaits freezes all of them —
|
|
28
|
+
* and nothing in-process can stop it, an abort signal included, since stopping needs the loop to yield.
|
|
29
|
+
* Only the stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
|
|
30
|
+
*
|
|
31
|
+
* Absent ⇒ a consumer compiles the code itself however it can, which drops every part of this at once
|
|
32
|
+
* and not just the bound: nothing is withheld from the body, `import()` reaches whatever the platform
|
|
33
|
+
* natively resolves rather than the loader it was passed, and {@link FunctionRunSettings.permittedImports} has no carrier and
|
|
34
|
+
* so restricts nothing. That is all a platform with no way to interrupt synchronous code
|
|
35
|
+
* and no module hook (the browser) can do, and it is the browser's permanent state rather than a
|
|
36
|
+
* misconfiguration. It is node's DEFAULT too: no host seeds a runner, so until an installation loads one
|
|
37
|
+
* (`@matatbread/matbot-function-runner-node`, or its own) a body runs in the host's process with full
|
|
38
|
+
* access — unbounded, with every global, and with `import()` reaching node's own loader unasked. Nothing
|
|
39
|
+
* warns about that at boot, because it is the configured behaviour rather than a fault.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* One invocation's reading of a {@link FunctionRunner}'s configuration ({@link FunctionRunner.settings}).
|
|
43
|
+
* A runner may carry its own fields beside these — the node runner's time bound — and reads them back
|
|
44
|
+
* off the value handed to {@link FunctionRunner.compile}'s callable; a consumer reads only what is
|
|
45
|
+
* declared here and otherwise passes the value through untouched.
|
|
29
46
|
*/
|
|
47
|
+
export interface FunctionRunSettings {
|
|
48
|
+
/**
|
|
49
|
+
* An optional RESTRICTION on what a body may import, as exact specifiers or prefixes (`['node:']`).
|
|
50
|
+
*
|
|
51
|
+
* **Absent ⇒ no restriction**, which is the default: every specifier may be asked about, and the
|
|
52
|
+
* per-import {@link PermissionGate} decision is the control. That way round because a gate that is
|
|
53
|
+
* never reached is not a gate — an allow-list defaulting to empty refuses before anyone can be asked,
|
|
54
|
+
* which is precisely the behaviour a permission prompt exists to replace. An empty array means the
|
|
55
|
+
* opposite of absent: nothing may be imported, and nobody is asked.
|
|
56
|
+
*
|
|
57
|
+
* What it is FOR is the case a prompt cannot express: a deployment that wants a module forbidden
|
|
58
|
+
* outright, with no human able to allow it. A policy has no stored "always deny" — a refusal is a
|
|
59
|
+
* decision about one act — so this is the only way to say "never", and it is installation authoring.
|
|
60
|
+
*
|
|
61
|
+
* It comes from the runner because the runner IS the execution environment, and because two consumers
|
|
62
|
+
* need one answer: the gate in front of the import, and the type index that declares the reachable set
|
|
63
|
+
* to the author. Each reads it once per invocation, through {@link FunctionRunner.settings}.
|
|
64
|
+
*
|
|
65
|
+
* An entry matches a specifier exactly, or as a prefix ending at a BOUNDARY — its own trailing `/` or
|
|
66
|
+
* `:`, or the next `/` in the specifier. So `node:` admits every builtin and `node:fs` admits
|
|
67
|
+
* `node:fs/promises`, while `lodash` does NOT admit `lodashhack`: a bare string prefix would let an
|
|
68
|
+
* entry admit a sibling whose name merely begins the same way, which is the whole value of naming one.
|
|
69
|
+
* It bounds what may be ASKED — it grants nothing by itself — and only ever among what
|
|
70
|
+
* {@link importableByName} admits, so an entry naming a path or a URL admits nothing.
|
|
71
|
+
*/
|
|
72
|
+
readonly permittedImports?: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What one call of a compiled function is handed besides its own arguments. */
|
|
76
|
+
export interface FunctionCall {
|
|
77
|
+
/** The consumer's gated loader for this invocation; see {@link FunctionRunner.compile}. */
|
|
78
|
+
readonly importModule: (spec: string, options?: ImportCallOptions) => Promise<unknown>;
|
|
79
|
+
/** What {@link FunctionRunner.settings} returned at the start of this invocation. Absent ⇒ the runner
|
|
80
|
+
* applies its own defaults, as it must for a value it did not produce. */
|
|
81
|
+
readonly settings?: FunctionRunSettings;
|
|
82
|
+
/** What a body's own `fetch` and `WebSocket` should be: the consumer's, which put each origin they reach
|
|
83
|
+
* to a {@link PermissionGate}, as `importModule` puts each module. A runner binds them in place of the
|
|
84
|
+
* globals; absent, or a runner that does not, leaves the body the platform's. Their types are the
|
|
85
|
+
* platform's own, so nothing a body is checked against changes. */
|
|
86
|
+
readonly fetch?: typeof fetch;
|
|
87
|
+
readonly WebSocket?: typeof WebSocket;
|
|
88
|
+
}
|
|
89
|
+
|
|
30
90
|
export interface FunctionRunner {
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Compile `source` — a TypeScript expression evaluating to an async function — and return something
|
|
93
|
+
* that calls it. Throws on a syntax error. A call stopped at the limit rejects with an error whose
|
|
94
|
+
* `code` is {@link FUNCTION_TIMEOUT}.
|
|
95
|
+
*
|
|
96
|
+
* An EXPRESSION, complete with its own parameter list, rather than a body plus names to bind: a body is
|
|
97
|
+
* not valid alone — `return` at its top level is a syntax error — so every runner would have to wrap it
|
|
98
|
+
* before it could strip or parse it.
|
|
99
|
+
*
|
|
100
|
+
* TypeScript, not erased JavaScript, because erasing is part of running it.
|
|
101
|
+
*
|
|
102
|
+
* `call` is this one invocation's: the loader a body's `import(…)` should reach (the consumer's gated
|
|
103
|
+
* loader, which closes over that invocation) and the {@link FunctionRunSettings} the consumer read for
|
|
104
|
+
* it. A runner that rewrites `import(…)` resolves it to `importModule`; one that does not simply ignores
|
|
105
|
+
* it. How it gets into scope — a parameter the runner names itself, a closure — is internal, and nothing
|
|
106
|
+
* outside declares or sees it: the type-check runs on the source as the author wrote it, before any of
|
|
107
|
+
* this.
|
|
108
|
+
*/
|
|
109
|
+
compile(source: string): (call: FunctionCall, ...args: unknown[]) => Promise<unknown>;
|
|
110
|
+
/**
|
|
111
|
+
* Read this runner's configuration for ONE invocation — a run, or a check — which the caller then holds
|
|
112
|
+
* for that invocation's whole extent and hands back on {@link FunctionCall.settings}.
|
|
113
|
+
*
|
|
114
|
+
* Read per invocation rather than once at boot, because whose settings they are is not something a
|
|
115
|
+
* runner can know: the settings store may be partitioned by principal or by nothing, which is the
|
|
116
|
+
* storage implementation's business. A boot read answered for the boot principal for ever after, and a
|
|
117
|
+
* per-principal cache would assume a partitioning nobody promised. An invocation, by contrast, has one
|
|
118
|
+
* principal throughout, so one read at its start is correct by construction, and every part of it — the
|
|
119
|
+
* gate, the type-check, the time bound — sees the same answer.
|
|
120
|
+
*
|
|
121
|
+
* Absent ⇒ nothing to configure: no restriction.
|
|
122
|
+
*/
|
|
123
|
+
settings?(): Promise<FunctionRunSettings>;
|
|
124
|
+
/**
|
|
125
|
+
* Turn a granted specifier into a module — a builtin or an installed package, never a path or a URL
|
|
126
|
+
* (see {@link importableByName}). The runner's rather than a plain `import()` at the call site because
|
|
127
|
+
* where a package resolves FROM is the runner's business: node's resolves from the runner's own module.
|
|
128
|
+
* `options` is the import's second argument, passed through as written, so an import attribute
|
|
129
|
+
* (`{ with: { type: 'json' } }`) reaches the loader. Absent ⇒ the caller falls back to a dynamic import.
|
|
130
|
+
*
|
|
131
|
+
* It is reached only after the gate has allowed this specifier for this function, and it performs no
|
|
132
|
+
* check of its own — a capability, not a policy.
|
|
133
|
+
*/
|
|
134
|
+
import?(spec: string, options?: ImportCallOptions): Promise<unknown>;
|
|
135
|
+
/**
|
|
136
|
+
* The spelling `spec` loads as, where the runner's platform has two for one module: node loads `os` as
|
|
137
|
+
* `node:os` whatever is installed, so asking about `os` and `node:os` separately asked twice about one
|
|
138
|
+
* module, and described a builtin as a package that "runs with matbot's full access" along with
|
|
139
|
+
* everything it imports. A consumer canonicalises BEFORE deciding, so one grant, one restriction and
|
|
140
|
+
* one label cover both spellings. Only a runner can answer, because the set of builtins is its
|
|
141
|
+
* platform's — `test` is a package to node, `node:test` the builtin.
|
|
142
|
+
*
|
|
143
|
+
* Absent ⇒ `spec` is taken as written.
|
|
144
|
+
*/
|
|
145
|
+
canonicalImport?(spec: string): string;
|
|
146
|
+
/**
|
|
147
|
+
* Identifiers a body does NOT have — anything this runner withholds, shadows or never binds (node's
|
|
148
|
+
* withholds `process`, `globalThis`, `global`, `eval` and `Function`, and names the phantom
|
|
149
|
+
* `require`/`module`/`__dirname` too, which never existed in a bare function expression but whose node
|
|
150
|
+
* types would otherwise make one typecheck and then fail at the first call).
|
|
151
|
+
*
|
|
152
|
+
* Stated here because only the runner knows, and a checker grading a body must REJECT them: a name
|
|
153
|
+
* shadowed but not rejected typechecks and fails at the first call, which is the one failure a check
|
|
154
|
+
* gate exists to prevent. The second consumer of a list is why it is not kept privately, exactly as
|
|
155
|
+
* for {@link FunctionRunSettings.permittedImports}.
|
|
156
|
+
*
|
|
157
|
+
* Absent ⇒ nothing is claimed, not that nothing is withheld.
|
|
158
|
+
*/
|
|
159
|
+
readonly withheldGlobals?: readonly string[];
|
|
34
160
|
}
|
|
35
161
|
|
|
36
162
|
/** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
|
|
37
163
|
* consumer can tell a stopped runaway from an ordinary failure without matching message text. */
|
|
38
164
|
export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
|
|
39
165
|
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether `spec` names a module the way a `tool_function` body may import one: a node builtin (`node:fs`)
|
|
169
|
+
* or an installed package (`lodash`, `@scope/pkg/sub`). Never a path, a URL or any other scheme.
|
|
170
|
+
*
|
|
171
|
+
* Downloading or placing code is what a plugin install is for. It is reviewed and versioned, and it has
|
|
172
|
+
* its own gate. A body is too lightweight a thing to recommend it from. Paths are refused for a second
|
|
173
|
+
* reason: they defeat {@link importPermitted}, which matches strings, so `./lib/../../x` "starts with"
|
|
174
|
+
* `./lib/` and resolves outside it. A `.` or `..` segment in a package subpath is refused for the same
|
|
175
|
+
* reason, because a package with no `exports` map resolves it as a path.
|
|
176
|
+
*
|
|
177
|
+
* The characters are an ALLOWLIST, because the resolver is a URL parser and decides what a dot segment is
|
|
178
|
+
* by its own rules: `%2e%2e`, `.%2E` and `.<tab>.` are all `..` to it (the parser strips tabs and
|
|
179
|
+
* newlines), and each one escaped the package from `typescript/…/package.json`. Enumerating encodings
|
|
180
|
+
* is a race against that spec; refusing `%`, whitespace and anything outside a package name's alphabet
|
|
181
|
+
* is not. No npm name contains any of them, and a subpath that needs one is not worth the hole.
|
|
182
|
+
*
|
|
183
|
+
* Shared because three places must agree: the gate in `function-tools`, the runner, and the type-check.
|
|
184
|
+
*/
|
|
185
|
+
export const importableByName = (spec: string): boolean =>
|
|
186
|
+
spec !== ''
|
|
187
|
+
&& !/[^\w@.~+!$&'()*,;=:/-]/.test(spec)
|
|
188
|
+
&& !/^[./]/.test(spec)
|
|
189
|
+
&& (spec.startsWith('node:') || !/^[a-z][a-z0-9+.-]*:/i.test(spec))
|
|
190
|
+
&& !spec.split('/').some(seg => seg === '.' || seg === '..');
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether {@link FunctionRunSettings.permittedImports} admits `spec` — the boundary rule that interface
|
|
194
|
+
* documents, as a function, because two packages read the same list and must agree: `function-tools`
|
|
195
|
+
* decides whether to ask about an import, and `tool-types` decides which modules to TYPE. They had a copy
|
|
196
|
+
* each, and they drifted exactly where it hurts — `node:fs` admitted `node:fs/promises` at the gate while
|
|
197
|
+
* the checker declared only `node:fs`, so a permitted subpath failed the check and needed `noTypeCheck`
|
|
198
|
+
* to run at all.
|
|
199
|
+
*
|
|
200
|
+
* A prefix must end at a boundary: its own trailing `/` or `:`, or the next `/` in the specifier. A bare
|
|
201
|
+
* `startsWith` let an entry admit a sibling whose name merely begins the same way, which is the whole
|
|
202
|
+
* value of naming one. A specifier {@link importableByName} refuses is never admitted, whatever the list
|
|
203
|
+
* says, since string matching cannot see what `..` does.
|
|
204
|
+
*/
|
|
205
|
+
export const importPermitted = (permits: readonly string[], spec: string): boolean =>
|
|
206
|
+
importableByName(spec) && permits.some(p => spec === p
|
|
207
|
+
|| (p.endsWith('/') || p.endsWith(':') ? spec.startsWith(p) : spec.startsWith(`${p}/`)));
|
|
208
|
+
|
|
40
209
|
/**
|
|
41
210
|
* One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
|
|
42
211
|
*
|
|
@@ -47,7 +216,8 @@ export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
|
|
|
47
216
|
* flattened `string[]` made every consumer do.
|
|
48
217
|
*/
|
|
49
218
|
export interface ToolCheckDiagnostic {
|
|
50
|
-
/** The rule's one name, in every renderer: `TS2339`, or
|
|
219
|
+
/** The rule's one name, in every renderer: `TS2339`, or a structural rule's own name — `CAST-GATE`,
|
|
220
|
+
* `ENV-GATE` for a global the runner does not define. */
|
|
51
221
|
label: string;
|
|
52
222
|
/** The numeric code. Cast-gate findings use a private 9000x range — read {@link syn}, not the number. */
|
|
53
223
|
code: number;
|
|
@@ -91,6 +261,13 @@ export interface ToolCheckReport {
|
|
|
91
261
|
diagnostics: ToolCheckDiagnostic[];
|
|
92
262
|
/** Present only when the cap hid some, tallied by the same `label` the detailed ones carry. */
|
|
93
263
|
omitted?: { count: number; byLabel: Record<string, number> };
|
|
264
|
+
/**
|
|
265
|
+
* The {@link ToolTypeIndex.environment} fingerprint this verdict was reached under — what the check
|
|
266
|
+
* depended on besides the snippet and the live tool types. A caller that PERSISTS a verdict stores it
|
|
267
|
+
* beside the source and compares it (string equality, never parsed) against the index's current one,
|
|
268
|
+
* so "this passed" can say which environment it passed in. Absent when nothing was checked.
|
|
269
|
+
*/
|
|
270
|
+
environment?: string;
|
|
94
271
|
}
|
|
95
272
|
|
|
96
273
|
/**
|
|
@@ -150,4 +327,23 @@ export interface ToolTypeIndex {
|
|
|
150
327
|
* outgoing tool descriptions at the turn's dispatch edge. A tool with only a loose `inputSchema` (no
|
|
151
328
|
* contract) is absent. */
|
|
152
329
|
wireContracts(): Promise<Record<string, { params: string; result: string }>>;
|
|
330
|
+
/**
|
|
331
|
+
* A fingerprint of everything a {@link check} verdict depends on APART from the snippet and the live
|
|
332
|
+
* tool types — in practice the `FunctionRunner` that will run the code: whether there is one, what it
|
|
333
|
+
* permits a body to import, and which globals it does not bind. Opaque: a consumer compares it for
|
|
334
|
+
* equality and never parses it.
|
|
335
|
+
*
|
|
336
|
+
* It exists because a stored verdict is a claim about an environment, and the environment can be
|
|
337
|
+
* swapped out from under it — unload the runner plugin, or narrow its permitted imports, and a body
|
|
338
|
+
* that passed stays marked as having passed while its `import(…)` is no longer gated, or no longer
|
|
339
|
+
* allowed at all. The tool types are deliberately NOT in it: those are live and `check` is how a
|
|
340
|
+
* caller re-verifies against them, whereas this names the thing a re-check cannot be prompted by.
|
|
341
|
+
*
|
|
342
|
+
* Stable across restarts, so never identity-derived, and derived at the point of use like everything
|
|
343
|
+
* else read off the runner — there is nothing here to invalidate or subscribe to.
|
|
344
|
+
*
|
|
345
|
+
* Optional, because an index written before it existed must degrade rather than throw: a caller with
|
|
346
|
+
* no fingerprint stores none, and compares nothing.
|
|
347
|
+
*/
|
|
348
|
+
environment?(): Promise<string>;
|
|
153
349
|
}
|
package/src/types/vault.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// ── Vault ─────────────────────────────────────────────────────────────────────
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The primitives a vault backend must implement.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The primitives a vault backend must implement. Redaction, and the low-level store operations the
|
|
5
|
+
* `resolve` and `createSecret` policies compose. A backend implements `Vault` (which is `VaultSpec`
|
|
6
|
+
* plus those policies); plugins are handed a `Vault`.
|
|
7
7
|
*/
|
|
8
8
|
export interface VaultSpec {
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The value stored under exactly `name`, or `undefined`. The literal read, as `writeSecret` is the
|
|
11
|
+
* literal write: a name is a name, with no placeholder syntax, so a caller holding one never encodes
|
|
12
|
+
* it into a `${NAME}` for `resolve` to parse back out — `A}${B` is simply absent, not two lookups.
|
|
13
|
+
*/
|
|
14
|
+
readSecret(name: string): Promise<string | undefined>;
|
|
11
15
|
scrub(text: string): string;
|
|
12
16
|
/**
|
|
13
17
|
* Store `value` under exactly `name`, overwriting. The literal write; no reference/dedup logic.
|
|
@@ -37,6 +41,11 @@ export interface VaultSpec {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface Vault extends VaultSpec {
|
|
44
|
+
/**
|
|
45
|
+
* Expand every `${NAME}` placeholder in `ref` with its stored value; throws `MissingSecretError`
|
|
46
|
+
* naming every miss. A backend's is `applyResolve(this, ref)`.
|
|
47
|
+
*/
|
|
48
|
+
resolve(ref: string): Promise<string>;
|
|
40
49
|
/**
|
|
41
50
|
* Store a secret coming (often) from a user via the LLM, where we cannot tell a real value
|
|
42
51
|
* from a key name they typed by mistake. Returns the key name callers MUST reference — not
|
package/src/vault.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import type { VaultSpec } from './types.js';
|
|
2
|
-
import { invalidSecretNameError } from './errors.js';
|
|
2
|
+
import { invalidSecretNameError, missingSecretError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
const PLACEHOLDER = /\$\{([^}]+)\}/g;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `resolve` policy, written once over `readSecret` so the placeholder grammar has one home — it was
|
|
8
|
+
* a regex and a replace loop copied into every backend, so a backend's idea of `${NAME}` could drift
|
|
9
|
+
* from the rule below that says which names can be stored. A backend's `resolve` is just
|
|
10
|
+
* `applyResolve(this, ref)`.
|
|
11
|
+
*/
|
|
12
|
+
export async function applyResolve(spec: VaultSpec, ref: string): Promise<string> {
|
|
13
|
+
const names = [...new Set(Array.from(ref.matchAll(PLACEHOLDER), m => m[1] ?? ''))];
|
|
14
|
+
const values = new Map(await Promise.all(names.map(async n => [n, await spec.readSecret(n)] as const)));
|
|
15
|
+
const missing = names.filter(n => values.get(n) === undefined);
|
|
16
|
+
if (missing.length > 0) throw missingSecretError(missing);
|
|
17
|
+
return ref.replace(PLACEHOLDER, (_, name: string) => values.get(name) ?? '');
|
|
18
|
+
}
|
|
3
19
|
|
|
4
20
|
/**
|
|
5
21
|
* The rule every vault shares, whatever else it adds: a secret is only reachable through a
|