@matatbread/matbot-core 0.4.13 → 0.4.15

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.13",
7
+ "version": "0.4.15",
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.13"
38
+ "@matatbread/matbot-plugin-api": "^0.4.15"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
@@ -21,6 +21,12 @@ export interface MatbotConfig {
21
21
  * config rather than data. A stored value always wins; `delete` reverts to the default here.
22
22
  */
23
23
  defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
24
+ /**
25
+ * Longest a `tool_function` body may run synchronously — without awaiting — before it is stopped
26
+ * (`function_timeout_ms`). Absent ⇒ the host's default. `0` ⇒ no limit at all, for testing: bodies run
27
+ * directly, and a loop that never awaits freezes the process.
28
+ */
29
+ functionTimeoutMs?: number;
24
30
  }
25
31
 
26
32
  function asString(v: YamlValue | undefined, label: string): string {
@@ -123,6 +129,17 @@ export function parseConfig(
123
129
  }
124
130
  }
125
131
 
132
+ const functionTimeoutRaw = doc['function_timeout_ms'];
133
+ let functionTimeoutMs: number | undefined;
134
+ if (functionTimeoutRaw !== undefined && functionTimeoutRaw !== null) {
135
+ functionTimeoutMs = asNumber(functionTimeoutRaw, 'function_timeout_ms');
136
+ // Refused rather than clamped, like maxRounds: a negative or fractional limit is a typo, and `0` already
137
+ // means something of its own.
138
+ if (!Number.isInteger(functionTimeoutMs) || functionTimeoutMs < 0) {
139
+ throw new Error(`Config: "function_timeout_ms" must be a non-negative integer (milliseconds; 0 removes the limit), got ${functionTimeoutMs}`);
140
+ }
141
+ }
142
+
126
143
  const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
127
144
  const ephemeral = doc['ephemeral'] === true ? true : undefined;
128
145
  const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
@@ -136,6 +153,7 @@ export function parseConfig(
136
153
  ...(defaultProvider !== undefined ? { defaultProvider } : {}),
137
154
  ...(principal !== undefined ? { principal } : {}),
138
155
  ...(defaultSettings.size > 0 ? { defaultSettings } : {}),
156
+ ...(functionTimeoutMs !== undefined ? { functionTimeoutMs } : {}),
139
157
  };
140
158
  }
141
159
 
package/src/index.ts CHANGED
@@ -23,7 +23,16 @@ export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE
23
23
  // that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
24
24
  // plugin-api dependency to get it.
25
25
  export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
26
- export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
26
+ export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, FUNCTION_TIMEOUT } 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, FormField, FrontendInfo, ProviderAdapter, ProviderConfig, FailedPlugin } from './types.js';
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
- * same name owned by a different plugin. Returns true to overwrite, false to keep the
51
- * existing one and drop the incoming registration.
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
- * Resolution order: a persisted "overwrite all (this install)" choice short-circuits to
54
- * true; otherwise the user is prompted [n / Y / all] with Y (overwrite) as the default.
55
- * 'all' persists the choice. With no prompt available (non-interactive host) we overwrite —
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
- if (prompt === undefined) {
75
- console.warn(`[matbot] ${label} — non-interactive, overwriting (default).`);
76
- return true;
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
- type: 'select',
83
- options: ['Keep existing', 'Overwrite', 'Always overwrite'],
84
- default: 'Overwrite',
85
- };
86
- const answer = (await prompt(field)).trim().toLowerCase();
87
- if (answer.startsWith('a')) { // "Always overwrite" — persist for the installation
88
- state.overwriteAllTools = true;
89
- await coreSettings.set(OVERWRITE_TOOLS_KEY, true);
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 !answer.startsWith('k'); // "Keep existing" → false; "Overwrite"/default → true
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 } : {}),
@@ -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
  }