@matatbread/matbot-core 0.4.13 → 0.4.14
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 +2 -2
- package/src/index.ts +9 -0
- package/src/registry.ts +41 -41
- package/src/runner.ts +12 -1
- package/src/session-runner.ts +7 -1
- package/src/settings.ts +28 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.14",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.14"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,15 @@ export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE
|
|
|
24
24
|
// plugin-api dependency to get it.
|
|
25
25
|
export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
|
|
26
26
|
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
27
|
+
// The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
|
|
28
|
+
// PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
|
|
29
|
+
export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
|
|
30
|
+
// A select option's value vs its label. Frontends render the label and return the value; a caller that
|
|
31
|
+
// branches on the answer compares values, never prose (see FormOption).
|
|
32
|
+
export { optionValue, optionLabel } from '@matatbread/matbot-plugin-api';
|
|
33
|
+
// Binds `ctx.gate` for a hand-assembled ToolContext — anything standing a tool-invocation door up
|
|
34
|
+
// outside the runner (frontend-web's `POST /tools/:name`) needs it, and gets it through core.
|
|
35
|
+
export { bindGate, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
27
36
|
// Ambient security principal: the interface is a type (carried by `export type *`); these are the values.
|
|
28
37
|
export { currentPrincipal, tryCurrentPrincipal, runAs, lastActivityAt } from '@matatbread/matbot-plugin-api';
|
|
29
38
|
// Host boot assembly, from plugin-api's `/host` subpath. An embedding app gets these through core and
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Tool, ToolRegistry, Hook, PromptFn,
|
|
1
|
+
import type { Tool, ToolRegistry, Hook, PromptFn, FrontendInfo, ProviderAdapter, ProviderConfig, FailedPlugin } from './types.js';
|
|
2
2
|
import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
3
|
-
import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
|
|
3
|
+
import { scopedNotifier, askPermissionGate } from '@matatbread/matbot-plugin-api/host';
|
|
4
4
|
import type {
|
|
5
5
|
MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
|
|
6
6
|
ProviderAdapterFactory, StoreFactory,
|
|
@@ -26,7 +26,6 @@ const state = {
|
|
|
26
26
|
systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
|
|
27
27
|
lifecycles: new Map<string, AbortController>(), // pluginName → "this plugin is loaded" signal, aborted on unload
|
|
28
28
|
failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
|
|
29
|
-
overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
// Plugin load/unload is announced on the Notifier as a `RegistryChange` with `registry: 'plugins'`, for
|
|
@@ -38,22 +37,21 @@ export function announcePluginLoaded(services: MatbotMachine, name: string): voi
|
|
|
38
37
|
services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name, operation: 'added' });
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
// Settings namespace + key under which the user's "overwrite all colliding tools" choice
|
|
42
|
-
// is persisted for the installation. The namespace doubles as a Store document id, so it must
|
|
43
|
-
// satisfy the storage id charset (/^[\w-]+$/) — hence underscores, not '@matbot/core'. The
|
|
44
|
-
// dunder marks it reserved (internal), so it won't collide with a real plugin's settings.
|
|
45
|
-
const CORE_SETTINGS_NS = '__matbot_core__';
|
|
46
|
-
const OVERWRITE_TOOLS_KEY = 'overwriteToolsOnCollision';
|
|
47
|
-
|
|
48
40
|
/**
|
|
49
|
-
* Decide whether an incoming tool registration may overwrite an existing tool of the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
41
|
+
* Decide whether an incoming tool registration may overwrite an existing tool of the same name owned
|
|
42
|
+
* by a different plugin. Returns true to overwrite, false to keep the existing one and drop the
|
|
43
|
+
* incoming registration.
|
|
44
|
+
*
|
|
45
|
+
* Core declares that the decision is needed and nothing else: the installation's `PermissionGate`
|
|
46
|
+
* decides how acceptance is obtained. Core used to own the whole policy here — a settings key in a
|
|
47
|
+
* reserved `__matbot_core__` namespace, a cached memo, a per-tool allowlist and two "always" options —
|
|
48
|
+
* which is an installation policy living in core and keyed in an LLM-writable store, and which an
|
|
49
|
+
* alternative installation could only defeat rather than replace. Remembering answers is now
|
|
50
|
+
* `@matatbread/matbot-default-gate`'s, out of its own plugin settings namespace.
|
|
52
51
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the default — preserving matbot's historical last-registration-wins behaviour.
|
|
52
|
+
* `fallback: true` is load-bearing: with no gate and no prompt (a boot load), a collision overwrites,
|
|
53
|
+
* preserving matbot's last-registration-wins behaviour and the deliberate override documented in
|
|
54
|
+
* docs/PER-USER-PLUGINS.md.
|
|
57
55
|
*/
|
|
58
56
|
async function resolveToolCollision(
|
|
59
57
|
services: MatbotMachine,
|
|
@@ -62,34 +60,24 @@ async function resolveToolCollision(
|
|
|
62
60
|
incomingOwner: string,
|
|
63
61
|
prompt: PromptFn | undefined,
|
|
64
62
|
): Promise<boolean> {
|
|
65
|
-
const coreSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), CORE_SETTINGS_NS);
|
|
66
|
-
if (state.overwriteAllTools === undefined) {
|
|
67
|
-
state.overwriteAllTools = (await coreSettings.get<boolean>(OVERWRITE_TOOLS_KEY)) ?? false;
|
|
68
|
-
}
|
|
69
|
-
if (state.overwriteAllTools) return true;
|
|
70
|
-
|
|
71
63
|
const owner = existingOwner !== undefined ? `"${existingOwner}"` : 'a built-in';
|
|
72
64
|
const label = `Tool \`"${toolName}"\` is already registered by **${owner}**. Overwrite it with the one from **"${incomingOwner}"**?`;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const field: FormField = {
|
|
80
|
-
name: 'overwrite',
|
|
65
|
+
const allowed = await (services.PermissionGate ?? askPermissionGate).decide({
|
|
66
|
+
gate: 'tools.overwrite',
|
|
67
|
+
subject: toolName,
|
|
68
|
+
// The incoming OWNER rides in the label rather than in a field of its own: a single `subject`
|
|
69
|
+
// cannot also express "trust everything plugin foo registers", and no policy wants that yet.
|
|
81
70
|
label,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return true;
|
|
71
|
+
fallback: true,
|
|
72
|
+
}, prompt);
|
|
73
|
+
// Said out loud when nobody could be asked, as it was before the gate existed: a boot-time overwrite
|
|
74
|
+
// is the one decision here that proceeds with no human and no record of itself, and "my tool silently
|
|
75
|
+
// changed hands" is exactly the report this line answers. A policy that overwrites after ASKING
|
|
76
|
+
// needs no warning — the user just saw the question.
|
|
77
|
+
if (prompt === undefined) {
|
|
78
|
+
console.warn(`[matbot] ${label} — non-interactive, ${allowed ? 'overwriting' : 'keeping the existing tool'}.`);
|
|
91
79
|
}
|
|
92
|
-
return
|
|
80
|
+
return allowed;
|
|
93
81
|
}
|
|
94
82
|
|
|
95
83
|
// ── Version check ─────────────────────────────────────────────────────────────
|
|
@@ -352,6 +340,18 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
352
340
|
specifier: plugin.specifier,
|
|
353
341
|
...(plugin.source !== undefined ? { source: plugin.source } : {}),
|
|
354
342
|
},
|
|
343
|
+
// Re-declared as a getter because the spread above COPIES: `{ ...services }` evaluates every
|
|
344
|
+
// getter on the host object exactly once, at plugin-load time. For the other swap-members that is
|
|
345
|
+
// harmless — the host's getters hand back capture-safe proxies, so a copied reference still follows
|
|
346
|
+
// the swap — but `PermissionGate` is deliberately NOT proxied (a policy composes by capturing the
|
|
347
|
+
// gate it displaces; through a proxy that capture resolves to itself, for ever). Copied, a plugin
|
|
348
|
+
// would hold whatever policy was active when IT loaded: a policy registered later would never be
|
|
349
|
+
// consulted by anything reading through this machine (frontend-web's `POST /tools/:name` route did
|
|
350
|
+
// exactly that), and unloading one would leave the copy pointing at the gone impl instead of
|
|
351
|
+
// reverting to the host's boot default. Reading through to `services` keeps it live per access,
|
|
352
|
+
// which is what every consumer here wants; a policy plugin's own `previous` capture is unaffected,
|
|
353
|
+
// being a deliberate read of the concrete gate at that moment.
|
|
354
|
+
get PermissionGate() { return services.PermissionGate; },
|
|
355
355
|
// Everything this plugin publishes is attributed to it by default — the notification analogue of
|
|
356
356
|
// stamping `pluginName` on its tools. Reads through the host's swap proxy, so a registered
|
|
357
357
|
// distributed Notifier takes effect for a plugin that captured this in setup().
|
package/src/runner.ts
CHANGED
|
@@ -2,13 +2,14 @@ import type {
|
|
|
2
2
|
Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
|
|
3
3
|
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
|
+
PermissionGate,
|
|
5
6
|
} from './types.js';
|
|
6
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
8
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
9
|
import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
|
|
9
10
|
import { HookRegistry } from './hooks.js';
|
|
10
11
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
-
import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
12
|
+
import { foldOntoUserTurn, bindPluginOps, bindGate } from '@matatbread/matbot-plugin-api';
|
|
12
13
|
import { addUsage } from './usage.js';
|
|
13
14
|
import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
14
15
|
|
|
@@ -114,6 +115,11 @@ export interface RunSessionOpts {
|
|
|
114
115
|
mediaStore?: MediaStore;
|
|
115
116
|
/** Supply a prompt implementation to allow tools to ask interactive questions. */
|
|
116
117
|
prompt?: PromptFn;
|
|
118
|
+
/** The installation's permission policy, consulted by `ctx.gate` at every privileged tool call site.
|
|
119
|
+
* The host passes its live `services.PermissionGate` (a capture-safe proxy, so a policy plugin
|
|
120
|
+
* registered mid-session takes effect); absent — a direct `runSession` caller or a test — the
|
|
121
|
+
* asking default stands in, which is the same behaviour the host boots with. */
|
|
122
|
+
permissionGate?: PermissionGate;
|
|
117
123
|
/**
|
|
118
124
|
* Turn-scoped context to inject ephemerally, exactly like a `screen` hook's `ephemeral` (tail-folded
|
|
119
125
|
* onto the freshest non-marker message, never persisted) — merged ahead of whatever `screen` adds.
|
|
@@ -585,6 +591,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
585
591
|
provider: config.provider,
|
|
586
592
|
prompt: promptFn,
|
|
587
593
|
...bindPluginOps(opts, promptFn),
|
|
594
|
+
// `opts.prompt`, deliberately NOT `promptFn`: the stand-in above answers with the field's own
|
|
595
|
+
// default, so a gate handed it could never tell "no human is reachable" from "a human answered
|
|
596
|
+
// with the default" — and would get the fallback back one layer too late to reason about.
|
|
597
|
+
// `ToolContext.prompt` keeps the stand-in exactly as before; only the gate sees the truth.
|
|
598
|
+
...bindGate(opts.permissionGate, tc.name, opts.prompt),
|
|
588
599
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
589
600
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
590
601
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
package/src/session-runner.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
|
|
3
3
|
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, MediaStore, UserContent, Tool,
|
|
4
4
|
ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
|
|
5
|
-
SteeringPolicy, SteeringDecision, TurnEntry,
|
|
5
|
+
SteeringPolicy, SteeringDecision, TurnEntry, PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
@@ -33,6 +33,10 @@ export interface SessionRunnerDeps {
|
|
|
33
33
|
// by-value media is written at the boundary, and where the runner reads it back from. Absent ⇒ an
|
|
34
34
|
// attachment is refused with a reason and a stored `file-ref` degrades to the converters' text note.
|
|
35
35
|
mediaStore?: () => MediaStore | undefined;
|
|
36
|
+
// Resolved live (the host boots a default, a plugin may replace it): the installation's permission
|
|
37
|
+
// policy, consulted by `ctx.gate` at every privileged tool call. Absent ⇒ the asking default, which
|
|
38
|
+
// is what the host boots with anyway.
|
|
39
|
+
permissionGate?: () => PermissionGate | undefined;
|
|
36
40
|
hooks?: HookRegistry;
|
|
37
41
|
systemContext?: SystemContextRegistry;
|
|
38
42
|
vault?: Vault;
|
|
@@ -363,6 +367,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
363
367
|
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
364
368
|
const toolPresenter = deps.toolPresenter?.();
|
|
365
369
|
const mediaStore = deps.mediaStore?.();
|
|
370
|
+
const permissionGate = deps.permissionGate?.();
|
|
366
371
|
const toolMap = deps.tools !== undefined
|
|
367
372
|
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
368
373
|
const wc = wire?.[t.name];
|
|
@@ -413,6 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
413
418
|
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
414
419
|
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
415
420
|
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
421
|
+
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
416
422
|
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
417
423
|
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
418
424
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
package/src/settings.ts
CHANGED
|
@@ -39,6 +39,26 @@ let installDefaults: ReadonlyMap<string, Readonly<Record<string, unknown>>> = ne
|
|
|
39
39
|
/** Host boot assembly: install the read-only defaults. Call before any plugin loads. */
|
|
40
40
|
export function installSettingsDefaults(byPlugin: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined): void {
|
|
41
41
|
installDefaults = byPlugin ?? new Map();
|
|
42
|
+
warnRetiredCoreSettings();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one retired core setting, named where it was authored. `__matbot_core__.overwriteToolsOnCollision`
|
|
47
|
+
* moved to the permission policy's own namespace and spelling when core stopped holding a policy, and it
|
|
48
|
+
* is deliberately NOT migrated — the standing answer is re-offered the next time that collision comes
|
|
49
|
+
* round. What an adopt-once path would have bought is one keystroke; what this buys is that an install
|
|
50
|
+
* which authored the key is told, rather than finding a prompt it thought it had answered. The dunder
|
|
51
|
+
* namespace stays reserved (the host's unmatched-namespace warning skips it), so nothing else says this.
|
|
52
|
+
*/
|
|
53
|
+
function warnRetiredCoreSettings(): void {
|
|
54
|
+
const core = installDefaults.get('__matbot_core__');
|
|
55
|
+
if (core === undefined || !('overwriteToolsOnCollision' in core)) return;
|
|
56
|
+
console.warn(
|
|
57
|
+
'[matbot] default_settings.__matbot_core__.overwriteToolsOnCollision no longer does anything: the ' +
|
|
58
|
+
'tool-collision decision is now a permission gate, and standing answers belong to the policy plugin. ' +
|
|
59
|
+
"Re-author it as default_settings.'@matatbread/matbot-default-gate'.'tools.overwrite' — the same list " +
|
|
60
|
+
'of tool names, or `true`.',
|
|
61
|
+
);
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
/** The plugin names the install supplies defaults for — the host checks these against what loaded. */
|
|
@@ -145,5 +165,13 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
|
|
|
145
165
|
return next;
|
|
146
166
|
});
|
|
147
167
|
},
|
|
168
|
+
async entries(): Promise<Record<string, unknown>> {
|
|
169
|
+
// The same layering `get` applies, in one pass: the stored document over the install default, so
|
|
170
|
+
// a stored `null` still reads as the override it is (it is a key `in` the document, and a later
|
|
171
|
+
// spread wins). One document read — the namespace is one document, and this is the same read a
|
|
172
|
+
// single `get` already makes.
|
|
173
|
+
const doc = await getDoc();
|
|
174
|
+
return { ...installDefaults.get(namespace), ...doc?.data };
|
|
175
|
+
},
|
|
148
176
|
};
|
|
149
177
|
}
|