@matatbread/matbot-plugin-api 0.4.20 → 0.4.22
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 +19 -0
- package/src/index.ts +3 -0
- package/src/invoke-tool.ts +21 -1
- 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/hooks.ts +42 -2
- package/src/types/permission.ts +1 -1
- package/src/types/provider.ts +7 -0
- package/src/types/registries.ts +6 -1
- package/src/types/tools.ts +6 -0
- package/src/types/typescript.ts +7 -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,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.
|
package/src/invoke-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
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/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
|
@@ -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
|
|
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>;
|
package/src/types/provider.ts
CHANGED
|
@@ -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
|
}
|
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
|
@@ -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
|
}
|
package/src/types/typescript.ts
CHANGED
|
@@ -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`, `
|
|
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
|
*
|