@capybara-hq/plugin-sdk 0.1.0-rc.0 → 0.1.0-rc.2

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/README.md CHANGED
@@ -14,8 +14,8 @@ npm packages; the host provides it at runtime.
14
14
  npm install @capybara-hq/plugin-sdk
15
15
  ```
16
16
 
17
- - Writing an extension: [.agents/skills/writing-extensions](https://github.com/capybara-hq/core/tree/main/.agents/skills/writing-extensions)
18
- and the reference plugin [plugins/example-counter](https://github.com/capybara-hq/core/tree/main/plugins/example-counter).
17
+ - Writing an extension: [.agents/skills/writing-extensions](https://github.com/capybara-hq/plugins/tree/main/.agents/skills/writing-extensions)
18
+ in capybara-hq/plugins and its reference plugin [plugins/example-counter](https://github.com/capybara-hq/plugins/tree/main/plugins/example-counter).
19
19
  - Writing a plugin (manifest, skills, connector templates, dependencies, marketplaces):
20
20
  [.agents/skills/writing-plugins](https://github.com/capybara-hq/core/tree/main/.agents/skills/writing-plugins).
21
21
  - The repository: [capybara-hq/core](https://github.com/capybara-hq/core).
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent templates (`agents/<name>.md` in a plugin; sub-agents spec §16.1): Markdown with YAML
3
3
  * frontmatter, the body being the instructions. A template is a type an agent with the builtin
4
- * `agent` tool can dispatch as a sub-agent (`<plugin>:<name>` in its roster). Used by the kernel,
4
+ * `Agent` tool can dispatch as a sub-agent (`<plugin>:<name>` in its roster). Used by the kernel,
5
5
  * the packer and `capybara-plugin check`.
6
6
  */
7
7
  import { readFrontmatter } from "./frontmatter.js";
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ import type { LocalizedName } from "./plugin-package.js";
3
+ /**
4
+ * Config UI hints (extension config forms spec §3): declarative presentation hints attached to an
5
+ * extension's config schema with {@link ui}, serialized as `x-ui` into the JSON Schema
6
+ * ({@link configJsonSchema}) and drawn by one generic renderer in the web app. Hints never change
7
+ * what validates: the zod schema stays the single source of truth for shape and validation.
8
+ */
9
+ /** A JSON Schema object (as produced by {@link configJsonSchema}). */
10
+ export type JsonSchema = Record<string, unknown>;
11
+ /** The input widget of a field. */
12
+ export type ConfigWidget = "text" | "textarea" | "url" | "number" | "select" | "radio" | "switch" | "checkbox" | "tristate" | "tags" | "json";
13
+ /** A labelled value of an enum (or boolean) field. */
14
+ export interface ConfigUiOption {
15
+ value: string | number | boolean;
16
+ label: LocalizedName;
17
+ help?: LocalizedName;
18
+ }
19
+ /** A root-level section that fields join with `section`. */
20
+ export interface ConfigUiSection {
21
+ id: string;
22
+ label: LocalizedName;
23
+ help?: LocalizedName;
24
+ }
25
+ /** A prefilled item offered when adding an item to an array of objects. */
26
+ export interface ConfigUiPreset {
27
+ id: string;
28
+ label: LocalizedName;
29
+ help?: LocalizedName;
30
+ /** Prefilled item fields; never a secret. */
31
+ values: Record<string, unknown>;
32
+ }
33
+ /** A secret drawn inside each item of an array, stored at a fixed record path keyed by an item field. */
34
+ export interface ConfigUiItemSecret {
35
+ /** Dot path, from the schema root, of a `secret(z.record(z.string(), z.string()))` field. */
36
+ path: string;
37
+ /** The item's string field whose value is the record key (e.g. `name`). */
38
+ keyBy: string;
39
+ label: LocalizedName;
40
+ help?: LocalizedName;
41
+ placeholder?: string | LocalizedName;
42
+ /** The item field the input is drawn after (default: last). */
43
+ after?: string;
44
+ }
45
+ /** Presentation hints of one config schema node (zod meta `ui`, JSON Schema `x-ui`). */
46
+ export interface ConfigUiHints {
47
+ label?: LocalizedName;
48
+ help?: LocalizedName;
49
+ placeholder?: string | LocalizedName;
50
+ /** Shown after a number input ("tokens", "ms"). */
51
+ unit?: LocalizedName;
52
+ /** Sort key among siblings (default: declaration order). */
53
+ order?: number;
54
+ /** Root-level section id (`sections` on the root). */
55
+ section?: string;
56
+ /** Collapsed under "Advanced" in its object or item. */
57
+ advanced?: boolean;
58
+ widget?: ConfigWidget;
59
+ options?: ConfigUiOption[];
60
+ /** Objects (and array items): fields stacked (default) or one compact row. */
61
+ layout?: "stack" | "row";
62
+ /** Arrays: how each item is drawn and added. */
63
+ item?: {
64
+ /** `{field}` template from the item's own scalar fields, e.g. "{name}". */
65
+ title?: string;
66
+ subtitle?: string;
67
+ addLabel?: LocalizedName;
68
+ reorder?: boolean;
69
+ };
70
+ presets?: ConfigUiPreset[];
71
+ secrets?: ConfigUiItemSecret[];
72
+ /** Root only. */
73
+ sections?: ConfigUiSection[];
74
+ /** Root only: localized messages for custom issues (`params: { ui: key }`). */
75
+ messages?: Record<string, LocalizedName>;
76
+ }
77
+ /** Limits of config UI hints and of the JSON Schemas a plugin package carries. */
78
+ export declare const CONFIG_UI_LIMITS: {
79
+ /** Characters per language of a `label` (also `unit`, `item.addLabel`). */
80
+ readonly labelChars: 100;
81
+ /** Characters per language of a `help` (also `messages` values). */
82
+ readonly helpChars: 500;
83
+ /** Characters (per language) of a `placeholder`. */
84
+ readonly placeholderChars: 200;
85
+ /** UTF-8 bytes of all `x-ui` JSON of one schema. */
86
+ readonly hintsBytes: number;
87
+ /** Options per node. */
88
+ readonly options: 200;
89
+ /** Presets per node. */
90
+ readonly presets: 20;
91
+ /** Root sections. */
92
+ readonly sections: 20;
93
+ /**
94
+ * UTF-8 bytes of each JSON Schema in `plugin.json` `extension.configSchemas` (96 KiB: two of
95
+ * them must fit in the 256 KiB `plugin.json` text with the rest of the manifest).
96
+ */
97
+ readonly configSchemaBytes: number;
98
+ };
99
+ /**
100
+ * Attaches config UI hints to a schema (zod meta `ui`), merging with existing meta (`secret`,
101
+ * `hot`, `description`). Returns the same schema type. Convention: put `ui()` outermost, after
102
+ * `.default()` / `.optional()`.
103
+ */
104
+ export declare function ui<T extends z.ZodTypeAny>(schema: T, hints: ConfigUiHints): T;
105
+ /**
106
+ * An extension config schema as a JSON Schema for forms (the input side, defaults included, no
107
+ * `$schema`):
108
+ * - meta `secret: true` becomes `"x-secret": true`, `hot: true` becomes `"x-hot": true`, `ui`
109
+ * becomes `"x-ui"` (a JSON copy of the hints); `title` / `description` are filled from
110
+ * `x-ui.label.en` / `x-ui.help.en` when the node has none;
111
+ * - unrepresentable types become `{ "x-unsupported": true }` instead of throwing;
112
+ * - under a secret node no value is published: `default` / `examples` / `const` / `enum` and
113
+ * `x-ui.options` / `x-ui.presets` are removed from it and all its descendants.
114
+ */
115
+ export declare function configJsonSchema(schema: z.ZodTypeAny): JsonSchema;
116
+ /** One problem of the hints of a config schema. */
117
+ export interface ConfigUiProblem {
118
+ /**
119
+ * Dot path of the node holding the hint: property names, with `items` / `additionalProperties`
120
+ * for array items and record values (`endpoints.items.models`); `""` is the root.
121
+ */
122
+ path: string;
123
+ message: string;
124
+ }
125
+ /**
126
+ * Validates the `x-ui` hints of a JSON Schema produced by {@link configJsonSchema} (spec §3.3) at
127
+ * a config level. Pure; used by the packer, `capybara-plugin check` and the kernel. Returns every
128
+ * problem (empty = valid); never throws. Problem paths are dot paths of the node holding the hint.
129
+ */
130
+ export declare function validateConfigUi(schema: JsonSchema, level: "deployment" | "org" | "connection"): ConfigUiProblem[];
@@ -0,0 +1,584 @@
1
+ import { z } from "zod";
2
+ /** Limits of config UI hints and of the JSON Schemas a plugin package carries. */
3
+ export const CONFIG_UI_LIMITS = {
4
+ /** Characters per language of a `label` (also `unit`, `item.addLabel`). */
5
+ labelChars: 100,
6
+ /** Characters per language of a `help` (also `messages` values). */
7
+ helpChars: 500,
8
+ /** Characters (per language) of a `placeholder`. */
9
+ placeholderChars: 200,
10
+ /** UTF-8 bytes of all `x-ui` JSON of one schema. */
11
+ hintsBytes: 64 * 1024,
12
+ /** Options per node. */
13
+ options: 200,
14
+ /** Presets per node. */
15
+ presets: 20,
16
+ /** Root sections. */
17
+ sections: 20,
18
+ /**
19
+ * UTF-8 bytes of each JSON Schema in `plugin.json` `extension.configSchemas` (96 KiB: two of
20
+ * them must fit in the 256 KiB `plugin.json` text with the rest of the manifest).
21
+ */
22
+ configSchemaBytes: 96 * 1024,
23
+ };
24
+ /**
25
+ * Attaches config UI hints to a schema (zod meta `ui`), merging with existing meta (`secret`,
26
+ * `hot`, `description`). Returns the same schema type. Convention: put `ui()` outermost, after
27
+ * `.default()` / `.optional()`.
28
+ */
29
+ export function ui(schema, hints) {
30
+ return schema.meta({ ...(schema.meta() ?? {}), ui: hints });
31
+ }
32
+ /** zod types JSON Schema cannot express: emitted as `{}` with `x-unsupported`. */
33
+ const UNSUPPORTED = new Set([
34
+ "bigint",
35
+ "date",
36
+ "symbol",
37
+ "undefined",
38
+ "void",
39
+ "nan",
40
+ "map",
41
+ "set",
42
+ "function",
43
+ "promise",
44
+ "custom",
45
+ "file",
46
+ ]);
47
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
48
+ /**
49
+ * An extension config schema as a JSON Schema for forms (the input side, defaults included, no
50
+ * `$schema`):
51
+ * - meta `secret: true` becomes `"x-secret": true`, `hot: true` becomes `"x-hot": true`, `ui`
52
+ * becomes `"x-ui"` (a JSON copy of the hints); `title` / `description` are filled from
53
+ * `x-ui.label.en` / `x-ui.help.en` when the node has none;
54
+ * - unrepresentable types become `{ "x-unsupported": true }` instead of throwing;
55
+ * - under a secret node no value is published: `default` / `examples` / `const` / `enum` and
56
+ * `x-ui.options` / `x-ui.presets` are removed from it and all its descendants.
57
+ */
58
+ export function configJsonSchema(schema) {
59
+ const out = z.toJSONSchema(schema, {
60
+ io: "input",
61
+ unrepresentable: "any",
62
+ override: (ctx) => {
63
+ const js = ctx.jsonSchema;
64
+ const def = ctx.zodSchema._zod.def;
65
+ if (js.secret === true)
66
+ js["x-secret"] = true;
67
+ delete js.secret;
68
+ if (js.hot === true)
69
+ js["x-hot"] = true;
70
+ delete js.hot;
71
+ if ("ui" in js) {
72
+ // A JSON copy: hints are data, and the scrub must never mutate the schema's meta.
73
+ const hints = JSON.parse(JSON.stringify(js.ui ?? null));
74
+ delete js.ui;
75
+ if (isObj(hints)) {
76
+ js["x-ui"] = hints;
77
+ const label = isObj(hints.label) ? hints.label.en : undefined;
78
+ const help = isObj(hints.help) ? hints.help.en : undefined;
79
+ if (js.title === undefined && typeof label === "string")
80
+ js.title = label;
81
+ if (js.description === undefined && typeof help === "string")
82
+ js.description = help;
83
+ }
84
+ }
85
+ if (UNSUPPORTED.has(def.type))
86
+ js["x-unsupported"] = true;
87
+ },
88
+ });
89
+ delete out.$schema;
90
+ scrubSecrets(out);
91
+ return out;
92
+ }
93
+ /** Keywords that would reveal values: never emitted for a secret. */
94
+ const VALUE_KEYWORDS = ["default", "examples", "const", "enum"];
95
+ /** Hints that would reveal values: never emitted for a secret. */
96
+ const VALUE_HINTS = ["options", "presets"];
97
+ /** Keywords whose value maps names to sub-schemas: their keys are field names, not keywords. */
98
+ const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
99
+ /**
100
+ * Removes value-revealing keywords from secret nodes and all their descendants (`inSecret` passed
101
+ * down). Only the keywords default / examples / const / enum are skipped as data; keys inside
102
+ * `properties` and other maps are field names, so fields named default / enum are traversed.
103
+ */
104
+ function scrubSecrets(node, inSecret = false) {
105
+ if (Array.isArray(node)) {
106
+ for (const n of node)
107
+ scrubSecrets(n, inSecret);
108
+ return;
109
+ }
110
+ if (!isObj(node))
111
+ return;
112
+ const secret = inSecret || node["x-secret"] === true;
113
+ if (secret) {
114
+ for (const k of VALUE_KEYWORDS)
115
+ delete node[k];
116
+ const hints = node["x-ui"];
117
+ if (isObj(hints))
118
+ for (const k of VALUE_HINTS)
119
+ delete hints[k];
120
+ }
121
+ for (const [k, v] of Object.entries(node)) {
122
+ if (k === "x-ui")
123
+ continue;
124
+ if (SCHEMA_MAPS.has(k) && isObj(v)) {
125
+ for (const child of Object.values(v))
126
+ scrubSecrets(child, secret);
127
+ }
128
+ else if (!VALUE_KEYWORDS.includes(k)) {
129
+ scrubSecrets(v, secret);
130
+ }
131
+ }
132
+ }
133
+ const WIDGETS = new Set([
134
+ "text",
135
+ "textarea",
136
+ "url",
137
+ "number",
138
+ "select",
139
+ "radio",
140
+ "switch",
141
+ "checkbox",
142
+ "tristate",
143
+ "tags",
144
+ "json",
145
+ ]);
146
+ const HINT_KEYS = new Set([
147
+ "label",
148
+ "help",
149
+ "placeholder",
150
+ "unit",
151
+ "order",
152
+ "section",
153
+ "advanced",
154
+ "widget",
155
+ "options",
156
+ "layout",
157
+ "item",
158
+ "presets",
159
+ "secrets",
160
+ "sections",
161
+ "messages",
162
+ ]);
163
+ /** The only hints a secret node may carry. */
164
+ const SECRET_HINT_KEYS = new Set(["label", "help", "placeholder", "order", "section", "advanced"]);
165
+ const ITEM_KEYS = new Set(["title", "subtitle", "addLabel", "reorder"]);
166
+ const utf8Bytes = (s) => new TextEncoder().encode(s).length;
167
+ /** The JSON types a node accepts (`type`, a type list, or the branches of a nullable `anyOf`). */
168
+ function typesOf(node) {
169
+ const t = node.type;
170
+ if (typeof t === "string")
171
+ return new Set([t]);
172
+ if (Array.isArray(t))
173
+ return new Set(t.filter((x) => typeof x === "string"));
174
+ const branches = (node.anyOf ?? node.oneOf);
175
+ if (Array.isArray(branches)) {
176
+ const out = new Set();
177
+ for (const b of branches)
178
+ if (isObj(b))
179
+ for (const x of typesOf(b))
180
+ out.add(x);
181
+ return out;
182
+ }
183
+ if ("const" in node)
184
+ return new Set([jsonType(node.const)]);
185
+ if (Array.isArray(node.enum))
186
+ return new Set(node.enum.map(jsonType));
187
+ return new Set();
188
+ }
189
+ /** The non-null shape of a node: itself, or the one non-null branch of a nullable `anyOf`. */
190
+ function shapeOf(node) {
191
+ const branches = (node.anyOf ?? node.oneOf);
192
+ if (node.type === undefined && Array.isArray(branches)) {
193
+ const rest = branches.filter((b) => isObj(b) && b.type !== "null");
194
+ if (rest.length === 1 && rest[0] !== undefined)
195
+ return rest[0];
196
+ }
197
+ return node;
198
+ }
199
+ function jsonType(v) {
200
+ if (v === null)
201
+ return "null";
202
+ if (Array.isArray(v))
203
+ return "array";
204
+ if (typeof v === "number")
205
+ return Number.isInteger(v) ? "integer" : "number";
206
+ return typeof v;
207
+ }
208
+ /** The enum values of a node (`enum`, `const`, or `anyOf` / `oneOf` branches of those), or null. */
209
+ function enumOf(node) {
210
+ if (Array.isArray(node.enum))
211
+ return node.enum;
212
+ if ("const" in node)
213
+ return [node.const];
214
+ const branches = (node.anyOf ?? node.oneOf);
215
+ if (Array.isArray(branches)) {
216
+ const out = [];
217
+ for (const b of branches) {
218
+ if (!isObj(b))
219
+ return null;
220
+ if (b.type === "null")
221
+ continue;
222
+ const e = enumOf(b);
223
+ if (e === null)
224
+ return null;
225
+ out.push(...e);
226
+ }
227
+ return out.length > 0 ? out : null;
228
+ }
229
+ return null;
230
+ }
231
+ const isNumberType = (types) => types.has("number") || types.has("integer");
232
+ const only = (types, ...allowed) => types.size > 0 && [...types].every((t) => t === "null" || allowed.includes(t));
233
+ const isScalar = (node) => {
234
+ const types = typesOf(node);
235
+ return !types.has("object") && !types.has("array");
236
+ };
237
+ /** Does `value` fit the JSON type (and enum) of `node`? */
238
+ function fits(node, value) {
239
+ const types = typesOf(node);
240
+ const t = jsonType(value);
241
+ const typeOk = types.size === 0 || types.has(t) || (t === "integer" && types.has("number"));
242
+ if (!typeOk)
243
+ return false;
244
+ const e = enumOf(node);
245
+ return e === null || e.some((x) => x === value);
246
+ }
247
+ function properties(node) {
248
+ const p = shapeOf(node).properties;
249
+ return isObj(p) ? p : {};
250
+ }
251
+ function itemsOf(node) {
252
+ const i = shapeOf(node).items;
253
+ return isObj(i) ? i : null;
254
+ }
255
+ /**
256
+ * Validates the `x-ui` hints of a JSON Schema produced by {@link configJsonSchema} (spec §3.3) at
257
+ * a config level. Pure; used by the packer, `capybara-plugin check` and the kernel. Returns every
258
+ * problem (empty = valid); never throws. Problem paths are dot paths of the node holding the hint.
259
+ */
260
+ export function validateConfigUi(schema, level) {
261
+ const problems = [];
262
+ const L = CONFIG_UI_LIMITS;
263
+ const rootHints = isObj(schema["x-ui"]) ? schema["x-ui"] : {};
264
+ const sectionIds = new Set(Array.isArray(rootHints.sections)
265
+ ? rootHints.sections.flatMap((s) => (isObj(s) && typeof s.id === "string" ? [s.id] : []))
266
+ : []);
267
+ let hintsBytes = 0;
268
+ const resolvePath = (dotPath) => {
269
+ let node = schema;
270
+ for (const seg of dotPath.split(".")) {
271
+ if (node === null)
272
+ return null;
273
+ const props = properties(node);
274
+ node = Object.hasOwn(props, seg) ? (props[seg] ?? null) : null;
275
+ }
276
+ return node;
277
+ };
278
+ const visit = (node, path, isRoot) => {
279
+ const report = (message) => problems.push({ path, message });
280
+ const text = (value, what, max, plainOk = false) => {
281
+ if (value === undefined)
282
+ return;
283
+ if (plainOk && typeof value === "string") {
284
+ if (value.trim() === "")
285
+ report(`${what} must not be empty`);
286
+ else if (value.length > max)
287
+ report(`${what} must be at most ${max} characters`);
288
+ return;
289
+ }
290
+ if (!isObj(value)) {
291
+ report(`${what} must be { zh, en }${plainOk ? " or a string" : ""}`);
292
+ return;
293
+ }
294
+ for (const lang of ["zh", "en"]) {
295
+ const v = value[lang];
296
+ if (typeof v !== "string" || v.trim() === "")
297
+ report(`${what}.${lang} must not be empty`);
298
+ else if (v.length > max)
299
+ report(`${what}.${lang} must be at most ${max} characters`);
300
+ }
301
+ for (const k of Object.keys(value)) {
302
+ if (k !== "zh" && k !== "en")
303
+ report(`${what} has an unknown key "${k}"`);
304
+ }
305
+ };
306
+ const raw = node["x-ui"];
307
+ if (raw !== undefined) {
308
+ hintsBytes += utf8Bytes(JSON.stringify(raw));
309
+ if (!isObj(raw))
310
+ report("x-ui must be an object");
311
+ else
312
+ checkHints(raw, node, isRoot, report, text);
313
+ }
314
+ const shape = shapeOf(node);
315
+ const props = shape.properties;
316
+ if (isObj(props)) {
317
+ for (const [k, child] of Object.entries(props)) {
318
+ if (isObj(child))
319
+ visit(child, path === "" ? k : `${path}.${k}`, false);
320
+ }
321
+ }
322
+ if (isObj(shape.items))
323
+ visit(shape.items, path === "" ? "items" : `${path}.items`, false);
324
+ if (isObj(shape.additionalProperties)) {
325
+ visit(shape.additionalProperties, path === "" ? "additionalProperties" : `${path}.additionalProperties`, false);
326
+ }
327
+ };
328
+ const checkHints = (h, node, isRoot, report, text) => {
329
+ const shape = shapeOf(node);
330
+ const types = typesOf(node);
331
+ const isArray = types.has("array");
332
+ const items = itemsOf(node);
333
+ const itemProps = items !== null && typesOf(items).has("object") ? properties(items) : null;
334
+ for (const k of Object.keys(h)) {
335
+ if (!HINT_KEYS.has(k))
336
+ report(`x-ui has an unknown hint "${k}"`);
337
+ }
338
+ if (node["x-secret"] === true) {
339
+ const bad = Object.keys(h).filter((k) => HINT_KEYS.has(k) && !SECRET_HINT_KEYS.has(k));
340
+ if (bad.length > 0) {
341
+ report(`a secret field allows only label, help, placeholder, order, section and advanced hints, not ${bad.join(", ")}`);
342
+ }
343
+ }
344
+ text(h.label, "label", L.labelChars);
345
+ text(h.help, "help", L.helpChars);
346
+ text(h.placeholder, "placeholder", L.placeholderChars, true);
347
+ text(h.unit, "unit", L.labelChars);
348
+ if (h.order !== undefined && (typeof h.order !== "number" || !Number.isFinite(h.order))) {
349
+ report("order must be a number");
350
+ }
351
+ if (h.advanced !== undefined && typeof h.advanced !== "boolean") {
352
+ report("advanced must be a boolean");
353
+ }
354
+ if (h.section !== undefined) {
355
+ if (typeof h.section !== "string" || !sectionIds.has(h.section)) {
356
+ report(`section "${String(h.section)}" is not an id of the root's sections`);
357
+ }
358
+ }
359
+ if (!isRoot && h.sections !== undefined)
360
+ report("sections are allowed only on the root");
361
+ if (!isRoot && h.messages !== undefined)
362
+ report("messages are allowed only on the root");
363
+ if (isRoot && h.sections !== undefined) {
364
+ if (!Array.isArray(h.sections))
365
+ report("sections must be an array");
366
+ else {
367
+ if (h.sections.length > L.sections)
368
+ report(`at most ${L.sections} sections`);
369
+ const ids = new Set();
370
+ h.sections.forEach((s, i) => {
371
+ if (!isObj(s) || typeof s.id !== "string" || s.id === "") {
372
+ report(`sections[${i}].id must be a non-empty string`);
373
+ return;
374
+ }
375
+ if (ids.has(s.id))
376
+ report(`duplicate section id "${s.id}"`);
377
+ ids.add(s.id);
378
+ text(s.label, `sections[${i}].label`, L.labelChars);
379
+ if (s.label === undefined)
380
+ report(`sections[${i}].label is required`);
381
+ text(s.help, `sections[${i}].help`, L.helpChars);
382
+ });
383
+ }
384
+ }
385
+ if (isRoot && h.messages !== undefined) {
386
+ if (!isObj(h.messages))
387
+ report("messages must be an object");
388
+ else
389
+ for (const [k, v] of Object.entries(h.messages))
390
+ text(v, `messages.${k}`, L.helpChars);
391
+ }
392
+ // widget fits the type
393
+ if (h.widget !== undefined) {
394
+ const w = h.widget;
395
+ const enumValues = enumOf(shape);
396
+ const ok = typeof w === "string" &&
397
+ WIDGETS.has(w) &&
398
+ (w === "json" ||
399
+ (["switch", "checkbox", "tristate"].includes(w) && only(types, "boolean")) ||
400
+ (["select", "radio"].includes(w) && enumValues !== null) ||
401
+ (w === "tags" && isArray && items !== null && only(typesOf(items), "string")) ||
402
+ (["url", "textarea", "text"].includes(w) && only(types, "string")) ||
403
+ (w === "number" && isNumberType(types) && only(types, "number", "integer")));
404
+ if (!ok)
405
+ report(`widget "${String(w)}" does not fit this field's type`);
406
+ }
407
+ // options: members of the enum (or booleans), each once
408
+ if (h.options !== undefined) {
409
+ if (!Array.isArray(h.options))
410
+ report("options must be an array");
411
+ else {
412
+ if (h.options.length > L.options)
413
+ report(`at most ${L.options} options`);
414
+ const allowed = enumOf(shape) ??
415
+ (isArray && items !== null ? enumOf(items) : null) ??
416
+ (only(types, "boolean") ? [true, false] : null);
417
+ const seen = [];
418
+ h.options.forEach((o, i) => {
419
+ if (!isObj(o)) {
420
+ report(`options[${i}] must be an object`);
421
+ return;
422
+ }
423
+ if (allowed === null)
424
+ report(`options[${i}]: this field has no enum to label`);
425
+ else if (!allowed.some((v) => v === o.value)) {
426
+ report(`options[${i}].value ${JSON.stringify(o.value)} is not a value of the field`);
427
+ }
428
+ if (seen.some((v) => v === o.value)) {
429
+ report(`options[${i}].value ${JSON.stringify(o.value)} is listed twice`);
430
+ }
431
+ seen.push(o.value);
432
+ text(o.label, `options[${i}].label`, L.labelChars);
433
+ if (o.label === undefined)
434
+ report(`options[${i}].label is required`);
435
+ text(o.help, `options[${i}].help`, L.helpChars);
436
+ });
437
+ }
438
+ }
439
+ // layout
440
+ if (h.layout !== undefined) {
441
+ if (h.layout !== "stack" && h.layout !== "row")
442
+ report('layout must be "stack" or "row"');
443
+ else if (h.layout === "row") {
444
+ const target = isArray && items !== null && typesOf(items).has("object")
445
+ ? items
446
+ : types.has("object")
447
+ ? node
448
+ : null;
449
+ if (target === null)
450
+ report('layout "row" applies only to objects and arrays of objects');
451
+ else {
452
+ const nonScalar = Object.entries(properties(target))
453
+ .filter(([, p]) => !isScalar(p))
454
+ .map(([k]) => k);
455
+ if (nonScalar.length > 0) {
456
+ report(`layout "row" needs scalar fields; not scalar: ${nonScalar.join(", ")}`);
457
+ }
458
+ }
459
+ }
460
+ }
461
+ // item / presets / secrets: arrays only; presets and secrets: arrays of objects
462
+ for (const k of ["item", "presets", "secrets"]) {
463
+ if (h[k] === undefined)
464
+ continue;
465
+ if (!isArray)
466
+ report(`${k} applies only to arrays`);
467
+ else if (k !== "item" && itemProps === null) {
468
+ report(`${k} applies only to arrays of objects`);
469
+ }
470
+ }
471
+ if (isObj(h.item) && isArray) {
472
+ for (const k of Object.keys(h.item)) {
473
+ if (!ITEM_KEYS.has(k))
474
+ report(`item has an unknown key "${k}"`);
475
+ }
476
+ for (const k of ["title", "subtitle"]) {
477
+ const tpl = h.item[k];
478
+ if (tpl === undefined)
479
+ continue;
480
+ if (typeof tpl !== "string") {
481
+ report(`item.${k} must be a string`);
482
+ continue;
483
+ }
484
+ for (const m of tpl.matchAll(/\{([^{}]*)\}/g)) {
485
+ const field = m[1] ?? "";
486
+ const p = itemProps !== null && Object.hasOwn(itemProps, field) ? itemProps[field] : null;
487
+ if (p === null || p === undefined || !isScalar(p)) {
488
+ report(`item.${k} names "${field}", which is not a scalar field of the item`);
489
+ }
490
+ }
491
+ }
492
+ text(h.item.addLabel, "item.addLabel", L.labelChars);
493
+ if (h.item.reorder !== undefined && typeof h.item.reorder !== "boolean") {
494
+ report("item.reorder must be a boolean");
495
+ }
496
+ }
497
+ else if (h.item !== undefined && isArray) {
498
+ report("item must be an object");
499
+ }
500
+ if (h.presets !== undefined && itemProps !== null) {
501
+ if (!Array.isArray(h.presets))
502
+ report("presets must be an array");
503
+ else {
504
+ if (h.presets.length > L.presets)
505
+ report(`at most ${L.presets} presets`);
506
+ const ids = new Set();
507
+ h.presets.forEach((p, i) => {
508
+ if (!isObj(p) || typeof p.id !== "string" || p.id === "") {
509
+ report(`presets[${i}].id must be a non-empty string`);
510
+ return;
511
+ }
512
+ if (ids.has(p.id))
513
+ report(`duplicate preset id "${p.id}"`);
514
+ ids.add(p.id);
515
+ text(p.label, `presets[${i}].label`, L.labelChars);
516
+ if (p.label === undefined)
517
+ report(`presets[${i}].label is required`);
518
+ text(p.help, `presets[${i}].help`, L.helpChars);
519
+ if (!isObj(p.values)) {
520
+ report(`presets[${i}].values must be an object`);
521
+ return;
522
+ }
523
+ for (const [k, v] of Object.entries(p.values)) {
524
+ const prop = Object.hasOwn(itemProps, k) ? itemProps[k] : undefined;
525
+ if (prop === undefined)
526
+ report(`presets[${i}].values.${k} is not a field of the item`);
527
+ else if (prop["x-secret"] === true) {
528
+ report(`presets[${i}].values.${k} is a secret; presets never carry secrets`);
529
+ }
530
+ else if (!fits(prop, v)) {
531
+ report(`presets[${i}].values.${k} does not match the field's type or values`);
532
+ }
533
+ }
534
+ });
535
+ }
536
+ }
537
+ if (h.secrets !== undefined && itemProps !== null) {
538
+ if (level !== "deployment") {
539
+ report("secrets drawn per item are allowed only in the deployment config");
540
+ }
541
+ if (!Array.isArray(h.secrets))
542
+ report("secrets must be an array");
543
+ else
544
+ h.secrets.forEach((s, i) => {
545
+ if (!isObj(s)) {
546
+ report(`secrets[${i}] must be an object`);
547
+ return;
548
+ }
549
+ const target = typeof s.path === "string" ? resolvePath(s.path) : null;
550
+ const ap = target === null ? null : shapeOf(target).additionalProperties;
551
+ if (target === null ||
552
+ target["x-secret"] !== true ||
553
+ !typesOf(target).has("object") ||
554
+ !isObj(ap) ||
555
+ ap.type !== "string") {
556
+ report(`secrets[${i}].path "${String(s.path)}" is not a secret record of strings (secret(z.record(z.string(), z.string())))`);
557
+ }
558
+ const keyProp = typeof s.keyBy === "string" && Object.hasOwn(itemProps, s.keyBy)
559
+ ? itemProps[s.keyBy]
560
+ : undefined;
561
+ if (keyProp === undefined || !only(typesOf(keyProp), "string")) {
562
+ report(`secrets[${i}].keyBy "${String(s.keyBy)}" is not a string field of the item`);
563
+ }
564
+ if (s.after !== undefined &&
565
+ (typeof s.after !== "string" || !Object.hasOwn(itemProps, s.after))) {
566
+ report(`secrets[${i}].after "${String(s.after)}" is not a field of the item`);
567
+ }
568
+ text(s.label, `secrets[${i}].label`, L.labelChars);
569
+ if (s.label === undefined)
570
+ report(`secrets[${i}].label is required`);
571
+ text(s.help, `secrets[${i}].help`, L.helpChars);
572
+ text(s.placeholder, `secrets[${i}].placeholder`, L.placeholderChars, true);
573
+ });
574
+ }
575
+ };
576
+ visit(schema, "", true);
577
+ if (hintsBytes > L.hintsBytes) {
578
+ problems.push({
579
+ path: "",
580
+ message: `x-ui hints total ${hintsBytes} bytes; at most ${L.hintsBytes} (64 KiB) per schema`,
581
+ });
582
+ }
583
+ return problems;
584
+ }
@@ -9,6 +9,11 @@ export interface ExtensionPointInfo {
9
9
  brokered: boolean;
10
10
  /** Enabled per org (consumers may pass `orgId` to check that the org enabled the provider). */
11
11
  orgScoped: boolean;
12
+ /**
13
+ * How the kernel picks one provider among several: `org-default` means each org chooses its
14
+ * default provider (absent: no selection, every enabled provider is used).
15
+ */
16
+ selection?: "org-default";
12
17
  }
13
18
  /** The registered extension points. */
14
19
  export declare const EXTENSION_POINTS: Record<string, ExtensionPointInfo>;
@@ -1,7 +1,8 @@
1
- const info = (cardinality, brokered = false, orgScoped = false) => ({
1
+ const info = (cardinality, brokered = false, orgScoped = false, selection) => ({
2
2
  cardinality,
3
3
  brokered,
4
4
  orgScoped,
5
+ ...(selection ? { selection } : {}),
5
6
  });
6
7
  /** The registered extension points. */
7
8
  export const EXTENSION_POINTS = {
@@ -16,6 +17,8 @@ export const EXTENSION_POINTS = {
16
17
  counter: info("multi"),
17
18
  connector: info("multi", true),
18
19
  "file-preview": info("multi", false, true),
20
+ "web-search": info("multi", true, true, "org-default"),
21
+ "web-fetch": info("multi", true, true, "org-default"),
19
22
  };
20
23
  /** Cardinality of the registered extension points (derived from EXTENSION_POINTS). */
21
24
  export const EXTENSION_POINT_CARDINALITY = Object.fromEntries(Object.entries(EXTENSION_POINTS).map(([k, v]) => [k, v.cardinality]));
package/dist/hooks.d.ts CHANGED
@@ -7,12 +7,12 @@ import type { ContentBlock, ModelRequest, ModelUsage } from "./model.js";
7
7
  export declare const HOOKS_AVAILABLE: readonly ["model.request", "model.response", "tool.preExecute", "tool.postExecute", "usage.report"];
8
8
  /** A mount point that `ctx.hooks.on` accepts. */
9
9
  export type AvailableHook = (typeof HOOKS_AVAILABLE)[number];
10
- /** Where a sub-agent was dispatched from: the parent conversation, its run and the `agent` call. */
10
+ /** Where a sub-agent was dispatched from: the parent conversation, its run and the `Agent` call. */
11
11
  export interface HookParentScope {
12
12
  conversationId: string;
13
13
  runId: string;
14
14
  /**
15
- * The `agent` call's id; `skill-<seq>` for a person's forked skill invocation, which has no
15
+ * The `Agent` call's id; `skill-<seq>` for a person's forked skill invocation, which has no
16
16
  * tool call in the parent's log.
17
17
  */
18
18
  callId: string;
@@ -35,9 +35,10 @@ export interface HookScope {
35
35
  * What a model call is for: `step` is a run step's call (the agent's turn), `compaction` is the call
36
36
  * that summarizes earlier history before a step when the context grows too large, `title` is the call
37
37
  * that writes a conversation's title after its first completed run (no run: `runId` is "", `step` 0,
38
- * `attempt` 1).
38
+ * `attempt` 1), `web_fetch` is the call that extracts an answer from a fetched web page for the
39
+ * `WebFetch` tool.
39
40
  */
40
- export type ModelCallPurpose = "step" | "compaction" | "title";
41
+ export type ModelCallPurpose = "step" | "compaction" | "title" | "web_fetch";
41
42
  /** What a reported model call was for (the same values as {@link ModelCallPurpose}). */
42
43
  export type UsagePurpose = ModelCallPurpose;
43
44
  /**
@@ -111,6 +112,7 @@ export interface ModelResponseHookPayload extends HookScope {
111
112
  export interface ToolPreHookPayload extends HookScope {
112
113
  step: number;
113
114
  callId: string;
115
+ /** The model-visible key: a builtin name, `mcp__<slug>__<tool>` or `plugin__<plugin>__<tool>`; split it with `parseToolKey`. */
114
116
  toolKey: string;
115
117
  specHash: string;
116
118
  input: unknown;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- export declare const SDK_VERSION = "0.1.0-rc.0";
1
+ export declare const SDK_VERSION = "0.1.0-rc.2";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
5
5
  export * from "./channel.js";
6
+ export * from "./config-ui.js";
6
7
  export * from "./connector.js";
7
8
  export * from "./connector-auth.js";
8
9
  export * from "./ctx.js";
@@ -24,4 +25,6 @@ export * from "./skill.js";
24
25
  export * from "./skill-invocation.js";
25
26
  export * from "./storage.js";
26
27
  export * from "./tool.js";
28
+ export * from "./tool-key.js";
27
29
  export * from "./ui.js";
30
+ export * from "./web.js";
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- export const SDK_VERSION = "0.1.0-rc.0";
1
+ export const SDK_VERSION = "0.1.0-rc.2";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
5
5
  export * from "./channel.js";
6
+ export * from "./config-ui.js";
6
7
  export * from "./connector.js";
7
8
  export * from "./connector-auth.js";
8
9
  export * from "./ctx.js";
@@ -24,4 +25,6 @@ export * from "./skill.js";
24
25
  export * from "./skill-invocation.js";
25
26
  export * from "./storage.js";
26
27
  export * from "./tool.js";
28
+ export * from "./tool-key.js";
27
29
  export * from "./ui.js";
30
+ export * from "./web.js";
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { type JsonSchema } from "./config-ui.js";
2
3
  import { type ConnectorTemplateSpec, type LocalizedName, type StaticExtensionManifest } from "./plugin-package.js";
3
4
  /**
4
5
  * The plugin manifest `.capybara-plugin/plugin.json` (package ecosystem spec §4.1). A plugin is a
@@ -74,6 +75,15 @@ export interface PluginExtensionSection {
74
75
  migrations?: string;
75
76
  /** `./ui`. */
76
77
  ui?: string;
78
+ /**
79
+ * The config JSON Schemas (`configJsonSchema`, with `x-ui` hints), written by the packer so an
80
+ * extension not loaded on a replica still gets its form; each ≤
81
+ * {@link CONFIG_UI_LIMITS}`.configSchemaBytes`. Not part of the `manifest_mismatch` comparison.
82
+ */
83
+ configSchemas?: {
84
+ deployment?: JsonSchema;
85
+ org?: JsonSchema;
86
+ };
77
87
  }
78
88
  /** `.capybara-plugin/plugin.json`. */
79
89
  export interface PluginManifest {
@@ -1,6 +1,7 @@
1
1
  import semver from "semver";
2
2
  import { z } from "zod";
3
3
  import { AGENT_TEMPLATE_LIMITS } from "./agent-template.js";
4
+ import { CONFIG_UI_LIMITS } from "./config-ui.js";
4
5
  import { defineExtension, EXTENSION_NAME_RE } from "./define-extension.js";
5
6
  import { CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, isSafePackagePath, SEMVER_VERSION_RE, validateConnectorTemplate, } from "./plugin-package.js";
6
7
  import { hasUpperBound } from "./semver-range.js";
@@ -87,6 +88,11 @@ const staticExtensionManifest = z.strictObject({
87
88
  ui: z.array(z.record(z.string(), z.unknown())).optional(),
88
89
  config: z.strictObject({ deployment: configFields, org: configFields }),
89
90
  });
91
+ const configSchemaJson = z
92
+ .record(z.string(), z.unknown())
93
+ .refine((v) => new TextEncoder().encode(JSON.stringify(v)).length <= CONFIG_UI_LIMITS.configSchemaBytes, {
94
+ message: `must be at most ${CONFIG_UI_LIMITS.configSchemaBytes} bytes of JSON (${CONFIG_UI_LIMITS.configSchemaBytes / 1024} KiB)`,
95
+ });
90
96
  const extensionSection = z.strictObject({
91
97
  entry: dotPath("entry").refine((p) => p.endsWith(".mjs"), {
92
98
  message: "entry must be a .mjs file",
@@ -94,6 +100,9 @@ const extensionSection = z.strictObject({
94
100
  manifest: staticExtensionManifest,
95
101
  migrations: dotPath("migrations").optional(),
96
102
  ui: dotPath("ui").optional(),
103
+ configSchemas: z
104
+ .strictObject({ deployment: configSchemaJson.optional(), org: configSchemaJson.optional() })
105
+ .optional(),
97
106
  });
98
107
  const schema = z
99
108
  .strictObject({
package/dist/secret.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { z } from "zod";
2
- /** 标记一个 schema 为敏感字段(Zod 4 meta `{ secret: true }`)。 */
2
+ /**
3
+ * Marks a schema as a secret field (Zod 4 meta `{ secret: true }`), merging with existing meta
4
+ * (`ui` hints, `hot`), so `secret(ui(x, …))` and `ui(secret(x), …)` are the same.
5
+ */
3
6
  export declare function secret<T extends z.ZodTypeAny>(schema: T): T;
4
7
  /** 读取 schema 的 meta,判断是否敏感;内核 `meta({secret:true})` 同样可识别。 */
5
8
  export declare function isSecret(schema: z.ZodTypeAny): boolean;
package/dist/secret.js CHANGED
@@ -1,6 +1,9 @@
1
- /** 标记一个 schema 为敏感字段(Zod 4 meta `{ secret: true }`)。 */
1
+ /**
2
+ * Marks a schema as a secret field (Zod 4 meta `{ secret: true }`), merging with existing meta
3
+ * (`ui` hints, `hot`), so `secret(ui(x, …))` and `ui(secret(x), …)` are the same.
4
+ */
2
5
  export function secret(schema) {
3
- return schema.meta({ secret: true });
6
+ return schema.meta({ ...(schema.meta() ?? {}), secret: true });
4
7
  }
5
8
  /** 读取 schema 的 meta,判断是否敏感;内核 `meta({secret:true})` 同样可识别。 */
6
9
  export function isSecret(schema) {
@@ -37,7 +37,7 @@ export declare function renderSkillBody(body: string, invocation: SkillInvocatio
37
37
  export interface RenderSkillInvocationInput {
38
38
  /**
39
39
  * The name the person typed after `/` (`fix-issue`, or `<plugin>:<name>`): used in the lead, the
40
- * `name="…"` attribute and the `skill_load` argument (where the qualified form resolves).
40
+ * `name="…"` attribute and the `Skill` argument (where the qualified form resolves).
41
41
  */
42
42
  name: string;
43
43
  /**
@@ -53,9 +53,9 @@ export interface RenderSkillInvocationInput {
53
53
  args: string;
54
54
  /** Decided by the caller from the agent's tools and the version's manifest. */
55
55
  lines: {
56
- /** The version has files besides SKILL.md: `load` when the agent has `skill_load`. */
56
+ /** The version has files besides SKILL.md: `load` when the agent has `Skill`. */
57
57
  files?: "load" | "unavailable";
58
- /** The body has shell injections and the agent has `sandbox_bash`. */
58
+ /** The body has shell injections and the agent has `Bash`. */
59
59
  shellHint?: boolean;
60
60
  };
61
61
  /** false: leave out the lead sentence (a forked skill's task for the sub-agent). Default true. */
@@ -180,13 +180,13 @@ export function renderSkillInvocation(i) {
180
180
  }
181
181
  parts.push(`<skill_invocation name="${escapeAttr(i.name)}" version="${i.version}">\n${body}\n</skill_invocation>`);
182
182
  if (i.lines.files === "load") {
183
- parts.push(`Its files are not in the sandbox yet: call skill_load with "${i.name}" to copy them to ${SKILLS_SANDBOX_ROOT}/${i.skillName}/ before running its scripts.`);
183
+ parts.push(`Its files are not in the sandbox yet: call Skill with "${i.name}" to copy them to ${SKILLS_SANDBOX_ROOT}/${i.skillName}/ before running its scripts.`);
184
184
  }
185
185
  else if (i.lines.files === "unavailable") {
186
186
  parts.push("Its other files are not available in this conversation.");
187
187
  }
188
188
  if (i.lines.shellHint) {
189
- parts.push("Lines written as !`command` were meant to run before you read this; run those commands with sandbox_bash if you need their output.");
189
+ parts.push("Lines written as !`command` were meant to run before you read this; run those commands with Bash if you need their output.");
190
190
  }
191
191
  return parts.join("\n\n");
192
192
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Model-visible tool key grammar (aligned with Claude Code):
3
+ *
4
+ * - builtin: `^[A-Z][A-Za-z]{1,31}$` (PascalCase), e.g. `Edit`
5
+ * - connector: `mcp__<slug>__<tool>`, e.g. `mcp__crm__search_accounts`
6
+ * - extension: `plugin__<plugin>__<tool>`, e.g. `plugin__todo__write`
7
+ *
8
+ * Pure functions only; extensions that match keys (hooks) use `parseToolKey` instead of splitting.
9
+ */
10
+ export type ParsedToolKey = {
11
+ kind: "builtin";
12
+ name: string;
13
+ } | {
14
+ kind: "connector";
15
+ slug: string;
16
+ tool: string;
17
+ } | {
18
+ kind: "extension";
19
+ plugin: string;
20
+ tool: string;
21
+ };
22
+ export declare const BUILTIN_TOOL_NAME_RE: RegExp;
23
+ /** Parse a tool key; `null` when it fits none of the three forms. */
24
+ export declare function parseToolKey(key: string): ParsedToolKey | null;
25
+ /** `mcp__<slug>__<tool>` */
26
+ export declare function connectorToolKey(slug: string, tool: string): string;
27
+ /** `plugin__<plugin>__<tool>` */
28
+ export declare function extensionToolKey(plugin: string, tool: string): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Model-visible tool key grammar (aligned with Claude Code):
3
+ *
4
+ * - builtin: `^[A-Z][A-Za-z]{1,31}$` (PascalCase), e.g. `Edit`
5
+ * - connector: `mcp__<slug>__<tool>`, e.g. `mcp__crm__search_accounts`
6
+ * - extension: `plugin__<plugin>__<tool>`, e.g. `plugin__todo__write`
7
+ *
8
+ * Pure functions only; extensions that match keys (hooks) use `parseToolKey` instead of splitting.
9
+ */
10
+ export const BUILTIN_TOOL_NAME_RE = /^[A-Z][A-Za-z]{1,31}$/;
11
+ const SEP = "__";
12
+ /** Parse a tool key; `null` when it fits none of the three forms. */
13
+ export function parseToolKey(key) {
14
+ if (!key.includes(SEP)) {
15
+ return BUILTIN_TOOL_NAME_RE.test(key) ? { kind: "builtin", name: key } : null;
16
+ }
17
+ const first = key.indexOf(SEP);
18
+ const family = key.slice(0, first);
19
+ if (family !== "mcp" && family !== "plugin")
20
+ return null;
21
+ const rest = key.slice(first + SEP.length);
22
+ const second = rest.indexOf(SEP);
23
+ if (second <= 0)
24
+ return null;
25
+ const owner = rest.slice(0, second);
26
+ const tool = rest.slice(second + SEP.length);
27
+ if (tool === "")
28
+ return null;
29
+ return family === "mcp"
30
+ ? { kind: "connector", slug: owner, tool }
31
+ : { kind: "extension", plugin: owner, tool };
32
+ }
33
+ /** `mcp__<slug>__<tool>` */
34
+ export function connectorToolKey(slug, tool) {
35
+ return `mcp${SEP}${slug}${SEP}${tool}`;
36
+ }
37
+ /** `plugin__<plugin>__<tool>` */
38
+ export function extensionToolKey(plugin, tool) {
39
+ return `plugin${SEP}${plugin}${SEP}${tool}`;
40
+ }
package/dist/web.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ /** Web search extension point ref (`web-search@1`). */
2
+ export declare const WEB_SEARCH_EXTENSION_POINT = "web-search@1";
3
+ /** Web fetch extension point ref (`web-fetch@1`). */
4
+ export declare const WEB_FETCH_EXTENSION_POINT = "web-fetch@1";
5
+ /** One search hit. */
6
+ export interface WebSearchResult {
7
+ title: string;
8
+ /** Absolute http / https URL. */
9
+ url: string;
10
+ /** Plain text or markdown, trimmed by the provider. */
11
+ snippet?: string;
12
+ /** ISO 8601 date or datetime, when known. */
13
+ publishedAt?: string;
14
+ }
15
+ /** A web search provider (`web-search@1`). The kernel passes the org's decrypted `orgConfig`. */
16
+ export interface WebSearchProvider {
17
+ /** False when this org's config lacks what the provider needs. */
18
+ configured?(orgConfig: unknown): boolean;
19
+ search(args: {
20
+ query: string;
21
+ /** Hints only: the kernel filters the results again. */
22
+ allowedDomains?: string[];
23
+ blockedDomains?: string[];
24
+ maxResults: number;
25
+ orgConfig: unknown;
26
+ signal: AbortSignal;
27
+ }): Promise<{
28
+ results: WebSearchResult[];
29
+ }>;
30
+ }
31
+ /** A fetched page, converted to markdown. */
32
+ export interface WebFetchPage {
33
+ /** After redirects; equal to the requested URL when there were none. */
34
+ finalUrl: string;
35
+ title?: string;
36
+ markdown: string;
37
+ contentType?: string;
38
+ }
39
+ /**
40
+ * A web fetch provider (`web-fetch@1`).
41
+ *
42
+ * The kernel checks the URL it passes and the returned `finalUrl` without DNS: it refuses IP
43
+ * literals in private, loopback, link-local or reserved ranges and single-label or `localhost`
44
+ * names. A provider that fetches from inside the deployment (rather than through a remote
45
+ * service) must also refuse non-public addresses after DNS resolution, for the URL and for every
46
+ * redirect it follows. It may reuse the kernel's rules: {@link isPublicHostname} on
47
+ * `new URL(url).hostname` (never a hand-parsed host), and on each resolved address it connects
48
+ * to (an IPv6 address in brackets, as in a URL).
49
+ */
50
+ export interface WebFetchProvider {
51
+ /** False when this org's config lacks what the provider needs. */
52
+ configured?(orgConfig: unknown): boolean;
53
+ fetch(args: {
54
+ /** Already validated and upgraded by the kernel. */
55
+ url: string;
56
+ orgConfig: unknown;
57
+ signal: AbortSignal;
58
+ }): Promise<WebFetchPage>;
59
+ }
60
+ /**
61
+ * Why a web provider call failed: `not_found` (404 / no content), `blocked` (the site refused the
62
+ * provider), `auth` (bad credentials), `rate_limited`, `unavailable` (provider down, 5xx, timeout),
63
+ * `invalid` (the provider rejected the query or URL).
64
+ */
65
+ export type WebProviderErrorKind = "not_found" | "blocked" | "auth" | "rate_limited" | "unavailable" | "invalid";
66
+ /** Thrown by web providers; the kernel maps `kind` to fixed, model-visible error text. */
67
+ export declare class WebProviderError extends Error {
68
+ readonly kind: WebProviderErrorKind;
69
+ constructor(message: string, opts: {
70
+ kind: WebProviderErrorKind;
71
+ });
72
+ }
73
+ /**
74
+ * Whether a URL hostname can name a public internet host. Pure, no DNS: false for `localhost`,
75
+ * single-label names, `*.localhost` / `*.local` / `*.internal`, and IP literals (IPv6 in brackets,
76
+ * as in a URL) in private, loopback, link-local, CGNAT, multicast, unspecified, documentation and
77
+ * reserved ranges, including IPv4-mapped IPv6. A name that resolves to a private address is not
78
+ * detected here.
79
+ */
80
+ export declare function isPublicHostname(host: string): boolean;
package/dist/web.js ADDED
@@ -0,0 +1,135 @@
1
+ /** Web search extension point ref (`web-search@1`). */
2
+ export const WEB_SEARCH_EXTENSION_POINT = "web-search@1";
3
+ /** Web fetch extension point ref (`web-fetch@1`). */
4
+ export const WEB_FETCH_EXTENSION_POINT = "web-fetch@1";
5
+ /** Thrown by web providers; the kernel maps `kind` to fixed, model-visible error text. */
6
+ export class WebProviderError extends Error {
7
+ kind;
8
+ constructor(message, opts) {
9
+ super(message);
10
+ this.name = "WebProviderError";
11
+ this.kind = opts.kind;
12
+ }
13
+ }
14
+ function ipv4Octets(host) {
15
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
16
+ if (!m)
17
+ return undefined;
18
+ const o = m.slice(1).map(Number);
19
+ return o.every((n) => n <= 255) ? o : undefined;
20
+ }
21
+ function isPublicIpv4(o) {
22
+ const [a, b, c] = o;
23
+ if (a === 0 || a === 10 || a === 127)
24
+ return false;
25
+ if (a === 100 && b >= 64 && b <= 127)
26
+ return false; // CGNAT
27
+ if (a === 169 && b === 254)
28
+ return false;
29
+ if (a === 172 && b >= 16 && b <= 31)
30
+ return false;
31
+ if (a === 192 && b === 168)
32
+ return false;
33
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
34
+ return false; // IETF protocol, TEST-NET-1
35
+ if (a === 192 && b === 88 && c === 99)
36
+ return false; // 6to4 relay
37
+ if (a === 198 && (b === 18 || b === 19))
38
+ return false; // benchmarking
39
+ if (a === 198 && b === 51 && c === 100)
40
+ return false; // TEST-NET-2
41
+ if (a === 203 && b === 0 && c === 113)
42
+ return false; // TEST-NET-3
43
+ if (a >= 224)
44
+ return false; // multicast, reserved, broadcast
45
+ return true;
46
+ }
47
+ /** Parses an IPv6 literal (no brackets) into eight 16-bit groups, or undefined. */
48
+ function ipv6Groups(host) {
49
+ let s = host;
50
+ const zone = s.indexOf("%");
51
+ if (zone >= 0)
52
+ s = s.slice(0, zone);
53
+ const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
54
+ if (tail) {
55
+ const o = ipv4Octets(tail[1]);
56
+ if (!o)
57
+ return undefined;
58
+ s = `${s.slice(0, tail.index)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
59
+ }
60
+ if (!/^[0-9a-f:]+$/i.test(s) || s.split("::").length > 2)
61
+ return undefined;
62
+ const [head = "", rest] = s.split("::");
63
+ const h = head === "" ? [] : head.split(":");
64
+ const t = rest === undefined || rest === "" ? [] : rest.split(":");
65
+ const parts = [...h, ...t];
66
+ if (!parts.every((p) => /^[0-9a-f]{1,4}$/i.test(p)))
67
+ return undefined;
68
+ let groups;
69
+ if (rest === undefined) {
70
+ if (h.length !== 8)
71
+ return undefined;
72
+ groups = h;
73
+ }
74
+ else {
75
+ if (h.length + t.length > 7)
76
+ return undefined;
77
+ groups = [...h, ...new Array(8 - h.length - t.length).fill("0"), ...t];
78
+ }
79
+ return groups.map((g) => Number.parseInt(g, 16));
80
+ }
81
+ function isPublicIpv6(g) {
82
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = g;
83
+ const zeros5 = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0;
84
+ if (zeros5 && g5 === 0 && g6 === 0 && (g7 === 0 || g7 === 1))
85
+ return false; // :: and ::1
86
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): judge the embedded address.
87
+ if (zeros5 && (g5 === 0xffff || g5 === 0)) {
88
+ return isPublicIpv4([g6 >> 8, g6 & 255, g7 >> 8, g7 & 255]);
89
+ }
90
+ if ((g0 & 0xfe00) === 0xfc00)
91
+ return false; // unique local fc00::/7
92
+ if ((g0 & 0xffc0) === 0xfe80)
93
+ return false; // link-local fe80::/10
94
+ if ((g0 & 0xffc0) === 0xfec0)
95
+ return false; // site-local (deprecated)
96
+ if ((g0 & 0xff00) === 0xff00)
97
+ return false; // multicast
98
+ if (g0 === 0x2001 && g1 === 0x0db8)
99
+ return false; // documentation
100
+ if (g0 === 0x0064 && g1 === 0xff9b)
101
+ return isPublicIpv4([g6 >> 8, g6 & 255, g7 >> 8, g7 & 255]); // NAT64
102
+ if (g0 === 0x2002)
103
+ return isPublicIpv4([g1 >> 8, g1 & 255, g2 >> 8, g2 & 255]); // 6to4
104
+ return true;
105
+ }
106
+ /**
107
+ * Whether a URL hostname can name a public internet host. Pure, no DNS: false for `localhost`,
108
+ * single-label names, `*.localhost` / `*.local` / `*.internal`, and IP literals (IPv6 in brackets,
109
+ * as in a URL) in private, loopback, link-local, CGNAT, multicast, unspecified, documentation and
110
+ * reserved ranges, including IPv4-mapped IPv6. A name that resolves to a private address is not
111
+ * detected here.
112
+ */
113
+ export function isPublicHostname(host) {
114
+ let h = host.trim().toLowerCase();
115
+ if (h.endsWith("."))
116
+ h = h.slice(0, -1);
117
+ if (h === "")
118
+ return false;
119
+ if (h.startsWith("[")) {
120
+ if (!h.endsWith("]"))
121
+ return false;
122
+ const g = ipv6Groups(h.slice(1, -1));
123
+ return g !== undefined && isPublicIpv6(g);
124
+ }
125
+ const o = ipv4Octets(h);
126
+ if (o)
127
+ return isPublicIpv4(o);
128
+ if (/^[\d.]+$/.test(h))
129
+ return false; // malformed or shorthand numeric host
130
+ if (!h.includes("."))
131
+ return false;
132
+ if (h === "localhost" || /\.(localhost|local|internal)$/.test(h))
133
+ return false;
134
+ return true;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-sdk",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0-rc.2",
4
4
  "description": "Capybara plugin SDK: the types and pure functions plugins are written against",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -310,6 +310,26 @@
310
310
  "minLength": 3,
311
311
  "maxLength": 512,
312
312
  "pattern": "^\\.\\/"
313
+ },
314
+ "configSchemas": {
315
+ "type": "object",
316
+ "properties": {
317
+ "deployment": {
318
+ "type": "object",
319
+ "propertyNames": {
320
+ "type": "string"
321
+ },
322
+ "additionalProperties": {}
323
+ },
324
+ "org": {
325
+ "type": "object",
326
+ "propertyNames": {
327
+ "type": "string"
328
+ },
329
+ "additionalProperties": {}
330
+ }
331
+ },
332
+ "additionalProperties": false
313
333
  }
314
334
  },
315
335
  "required": [