@matatbread/matbot-plugin-api 0.4.20 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.20",
7
+ "version": "0.4.21",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -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,6 +1,7 @@
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';
@@ -192,6 +193,24 @@ export class HookRegistry implements HookRegistrar {
192
193
  return {};
193
194
  }
194
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
+
195
214
  // toolresult folds the tool result through each hook — a transform pipeline (redaction, truncation);
196
215
  // a hook that returns nothing just observes (auditing). Returns the final result.
197
216
  async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
package/src/index.ts CHANGED
@@ -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.
@@ -58,11 +58,31 @@ export function bindGate(
58
58
  && (owner === undefined || tools?.resolve(namespace)?.pluginName !== owner)) {
59
59
  throw new Error(`Tool "${name}" cannot ask permission under "${namespace}": a gate namespace must be a tool registered by the same plugin.`);
60
60
  }
61
- return (gate ?? askPermissionGate).decide({ ...req, gate: `${namespace ?? name}.${req.gate}` }, ask);
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;
62
65
  },
63
66
  };
64
67
  }
65
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
+
66
86
  /**
67
87
  * Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
68
88
  * forwarding a call to another tool should pass its own {@link ToolContext} straight through as
@@ -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
+ }
@@ -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` and therefore ride through untouched; see
16
- * that type for why neither belongs in an update.
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. And **`module`**, because changing the
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
  *
@@ -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> };
@@ -120,7 +120,7 @@ export function refusalMessage(decision: PermissionDecision, act?: string): stri
120
120
  * means **no human is reachable**; a policy that wants to behave differently then simply checks it.
121
121
  *
122
122
  * A gate that wants "my rules, else the previous behaviour" captures the gate it displaces and
123
- * delegates to it (the `ToolCallValidator` idiom), so a pair composes in either load order.
123
+ * delegates to it, so a pair composes in either load order.
124
124
  */
125
125
  export interface PermissionGate {
126
126
  decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<PermissionDecision>;
@@ -264,4 +264,11 @@ export interface ProviderAdapter {
264
264
  signal: AbortSignal
265
265
  ): AsyncIterable<CompletionEvent>;
266
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[]>;
267
274
  }
@@ -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. */
@@ -314,6 +314,12 @@ export interface Tool<R = unknown> {
314
314
  * its contract as a `ToolContracts` augmentation instead and omits this. Foreign tools (e.g. MCP proxies)
315
315
  * that have neither fall back to the loose `inputSchema`. */
316
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;
317
323
  executor: ToolExecutor<R>;
318
324
  pluginName?: string;
319
325
  }
@@ -79,6 +79,12 @@ export interface FunctionCall {
79
79
  /** What {@link FunctionRunner.settings} returned at the start of this invocation. Absent ⇒ the runner
80
80
  * applies its own defaults, as it must for a value it did not produce. */
81
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;
82
88
  }
83
89
 
84
90
  export interface FunctionRunner {
@@ -139,7 +145,7 @@ export interface FunctionRunner {
139
145
  canonicalImport?(spec: string): string;
140
146
  /**
141
147
  * Identifiers a body does NOT have — anything this runner withholds, shadows or never binds (node's
142
- * withholds `process`, `globalThis`, `Buffer`, `eval` and `Function`, and names the phantom
148
+ * withholds `process`, `globalThis`, `global`, `eval` and `Function`, and names the phantom
143
149
  * `require`/`module`/`__dirname` too, which never existed in a bare function expression but whose node
144
150
  * types would otherwise make one typecheck and then fail at the first call).
145
151
  *