@capybara-hq/plugin-sdk 0.0.0-stage → 0.1.0-rc.1

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.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +27 -2
  4. package/dist/agent-template.d.ts +47 -0
  5. package/dist/agent-template.js +123 -0
  6. package/dist/bootstrap.d.ts +67 -0
  7. package/dist/bootstrap.js +109 -0
  8. package/dist/cards.d.ts +64 -0
  9. package/dist/cards.js +217 -0
  10. package/dist/channel.d.ts +230 -0
  11. package/dist/channel.js +26 -0
  12. package/dist/connector-auth.d.ts +64 -0
  13. package/dist/connector-auth.js +96 -0
  14. package/dist/connector.d.ts +154 -0
  15. package/dist/connector.js +1 -0
  16. package/dist/ctx.d.ts +176 -0
  17. package/dist/ctx.js +1 -0
  18. package/dist/define-extension.d.ts +41 -0
  19. package/dist/define-extension.js +85 -0
  20. package/dist/directory.d.ts +29 -0
  21. package/dist/directory.js +1 -0
  22. package/dist/extension-point.d.ts +29 -0
  23. package/dist/extension-point.js +36 -0
  24. package/dist/file-preview.d.ts +64 -0
  25. package/dist/file-preview.js +72 -0
  26. package/dist/frontmatter.d.ts +12 -0
  27. package/dist/frontmatter.js +36 -0
  28. package/dist/hooks.d.ts +195 -0
  29. package/dist/hooks.js +14 -0
  30. package/dist/identity.d.ts +44 -0
  31. package/dist/identity.js +1 -0
  32. package/dist/index.d.ts +28 -0
  33. package/dist/index.js +28 -0
  34. package/dist/marketplace.d.ts +99 -0
  35. package/dist/marketplace.js +256 -0
  36. package/dist/model-params.d.ts +82 -0
  37. package/dist/model-params.js +222 -0
  38. package/dist/model.d.ts +227 -0
  39. package/dist/model.js +152 -0
  40. package/dist/permissions.d.ts +67 -0
  41. package/dist/permissions.js +271 -0
  42. package/dist/plugin-manifest.d.ts +141 -0
  43. package/dist/plugin-manifest.js +322 -0
  44. package/dist/plugin-package.d.ts +139 -0
  45. package/dist/plugin-package.js +448 -0
  46. package/dist/sandbox.d.ts +152 -0
  47. package/dist/sandbox.js +96 -0
  48. package/dist/secret.d.ts +5 -0
  49. package/dist/secret.js +8 -0
  50. package/dist/semver-range.d.ts +6 -0
  51. package/dist/semver-range.js +12 -0
  52. package/dist/skill-invocation.d.ts +83 -0
  53. package/dist/skill-invocation.js +201 -0
  54. package/dist/skill-keys.d.ts +9 -0
  55. package/dist/skill-keys.js +16 -0
  56. package/dist/skill.d.ts +77 -0
  57. package/dist/skill.js +244 -0
  58. package/dist/storage.d.ts +96 -0
  59. package/dist/storage.js +9 -0
  60. package/dist/tool-key.d.ts +28 -0
  61. package/dist/tool-key.js +40 -0
  62. package/dist/tool.d.ts +154 -0
  63. package/dist/tool.js +218 -0
  64. package/dist/ui.d.ts +265 -0
  65. package/dist/ui.js +358 -0
  66. package/dist/url.d.ts +2 -0
  67. package/dist/url.js +12 -0
  68. package/package.json +40 -3
  69. package/schemas/bootstrap.v1.schema.json +125 -0
  70. package/schemas/connector-template.v1.schema.json +116 -0
  71. package/schemas/marketplace.v1.schema.json +270 -0
  72. package/schemas/plugin.v1.schema.json +330 -0
@@ -0,0 +1,448 @@
1
+ import semver from "semver";
2
+ import { ConnectorAuthSchema } from "./connector-auth.js";
3
+ import { EXTENSION_NAME_RE } from "./define-extension.js";
4
+ /**
5
+ * Plugin package (npm-pack tgz) rules shared by the SDK, the packer and the kernel (spec §4.1). Pure
6
+ * functions only: the kernel reads the tgz and scans imports, `packages/plugin-tools` packs it. The
7
+ * manifest is `.capybara-plugin/plugin.json` (plugin-manifest.ts); an extension's static manifest
8
+ * ({@link StaticExtensionManifest}) is written into it by the packer and compared with the real
9
+ * module's {@link staticManifestOf} at load (a difference is `manifest_mismatch`). The extension
10
+ * entry is single-file ESM with third-party dependencies bundled; only {@link HOST_PROVIDED_PACKAGES}
11
+ * come from the host.
12
+ */
13
+ /** 宿主提供的包(peerDependencies,安装时链接到宿主副本);其余三方依赖必须打进 entry。 */
14
+ export const HOST_PROVIDED_PACKAGES = ["@capybara-hq/plugin-sdk", "zod"];
15
+ /** npm 安装时会自动执行的脚本:插件包一律不许有(安装不跑脚本,有也是误导)。 */
16
+ export const FORBIDDEN_INSTALL_SCRIPTS = [
17
+ "preinstall",
18
+ "install",
19
+ "postinstall",
20
+ "prepare",
21
+ ];
22
+ /** 不许出现的依赖字段:三方依赖必须打进 entry。 */
23
+ const FORBIDDEN_DEP_FIELDS = [
24
+ "dependencies",
25
+ "optionalDependencies",
26
+ "bundleDependencies",
27
+ "bundledDependencies",
28
+ ];
29
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ /** npm 包名规则(简化):可带 scope,小写,≤ 214 字符(长度另行检查)。 */
31
+ export const NPM_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
32
+ const NPM_NAME_RE = NPM_PACKAGE_NAME_RE;
33
+ /**
34
+ * 包内相对路径是否安全:非空、不以 `/` 开头、不含 `\`、控制字符与盘符,没有空段、`.` 与 `..` 段,≤ 1024 字符。
35
+ */
36
+ export function isSafePackagePath(p) {
37
+ if (typeof p !== "string" || p === "" || p.length > 1024)
38
+ return false;
39
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: 正是要拒绝控制字符
40
+ if (p.startsWith("/") || p.includes("\\") || /[\u0000-\u001f\u007f]/.test(p))
41
+ return false;
42
+ if (/^[A-Za-z]:/.test(p))
43
+ return false;
44
+ return p.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
45
+ }
46
+ const isHostPackage = (name) => HOST_PROVIDED_PACKAGES.some((h) => name === h || name.startsWith(`${h}/`));
47
+ /**
48
+ * 核对入口模块的 import(C11):静态与字面量动态 import 只许 `node:*`、{@link HOST_PROVIDED_PACKAGES}(含子路径)
49
+ * 与包内相对路径(`./`、`../`;是否越出包外由调用方按文件位置判断);非字面量的动态 import 一律拒绝。
50
+ * 返回问题列表(空表示合法)。
51
+ */
52
+ export function checkEntryImports(specifiers) {
53
+ const out = [];
54
+ for (const s of specifiers) {
55
+ if (s.dynamic && !s.literal) {
56
+ out.push("dynamic import() with a non-literal specifier is not allowed");
57
+ continue;
58
+ }
59
+ const n = s.name;
60
+ if (n.startsWith("node:") && n.length > 5)
61
+ continue;
62
+ if (isHostPackage(n))
63
+ continue;
64
+ if (n.startsWith("./") || n.startsWith("../"))
65
+ continue;
66
+ out.push(`import of "${n}" is not allowed: bundle third-party dependencies into the entry ` +
67
+ `(only node:*, ${HOST_PROVIDED_PACKAGES.join(", ")} and relative paths are resolved at load)`);
68
+ }
69
+ return out;
70
+ }
71
+ const WRAPPERS = new Set(["default", "prefault", "optional", "nullable", "readonly", "catch"]);
72
+ const defOf = (s) => s._zod.def;
73
+ const metaOf = (s) => s.meta();
74
+ /** 与内核 `configFields` 相同的遍历:对象递归展开,default / optional 等包装剥掉,对象上的 secret 向下继承。 */
75
+ function configFieldsOf(schema, prefix = "", inheritedSecret = false) {
76
+ if (!schema)
77
+ return [];
78
+ let node = schema;
79
+ let meta = metaOf(node);
80
+ while (WRAPPERS.has(defOf(node).type) && defOf(node).innerType) {
81
+ node = defOf(node).innerType;
82
+ meta ??= metaOf(node);
83
+ }
84
+ const secret = inheritedSecret || meta?.secret === true;
85
+ const def = defOf(node);
86
+ if (def.type === "object" && def.shape) {
87
+ return Object.entries(def.shape).flatMap(([k, v]) => configFieldsOf(v, prefix ? `${prefix}.${k}` : k, secret));
88
+ }
89
+ return [{ path: prefix, secret, hot: meta?.hot === true }];
90
+ }
91
+ const byPath = (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
92
+ const jsonClone = (v) => JSON.parse(JSON.stringify(v));
93
+ /**
94
+ * 真实清单的静态部分:打包时写进 `capybara.manifest`,加载时与包内的比对(`manifest_mismatch`)。
95
+ * 结果可 JSON 往返(undefined 字段去掉);配置字段按路径排序。
96
+ */
97
+ export function staticManifestOf(m) {
98
+ const out = {
99
+ name: m.name,
100
+ version: m.version,
101
+ sdk: m.sdk,
102
+ provides: [...m.provides],
103
+ requires: [...(m.requires ?? [])],
104
+ optional: [...(m.optional ?? [])],
105
+ permissions: jsonClone(m.permissions ?? {}),
106
+ config: {
107
+ deployment: configFieldsOf(m.config?.deployment).sort(byPath),
108
+ org: configFieldsOf(m.config?.org).sort(byPath),
109
+ },
110
+ };
111
+ if (m.ui !== undefined)
112
+ out.ui = jsonClone(m.ui);
113
+ return out;
114
+ }
115
+ /**
116
+ * The package.json rules shared by every plugin tgz: npm name, semver version, no install scripts,
117
+ * no `gypfile`, no dependency fields, `peerDependencies` only host packages, and a `zod` peer range
118
+ * that is valid (and satisfied by `hostZodVersion` when given). Returns the peerDependencies object.
119
+ */
120
+ function checkPluginPackageBasics(pkg, opts, errs) {
121
+ if (typeof pkg.name !== "string" || pkg.name.length > 214 || !NPM_NAME_RE.test(pkg.name)) {
122
+ errs.push("package.json name must be a valid npm package name");
123
+ }
124
+ if (typeof pkg.version !== "string" || semver.valid(pkg.version) === null) {
125
+ errs.push("package.json version must be a valid semver version");
126
+ }
127
+ if (pkg.scripts !== undefined) {
128
+ if (!isObj(pkg.scripts))
129
+ errs.push("package.json scripts must be an object");
130
+ else {
131
+ for (const s of FORBIDDEN_INSTALL_SCRIPTS) {
132
+ if (Object.hasOwn(pkg.scripts, s)) {
133
+ errs.push(`package.json scripts.${s} is not allowed: plugin packages never run scripts`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ if (pkg.gypfile !== undefined && pkg.gypfile !== false) {
139
+ errs.push("package.json gypfile is not allowed: native addons are not supported");
140
+ }
141
+ for (const f of FORBIDDEN_DEP_FIELDS) {
142
+ const v = pkg[f];
143
+ if (v === undefined)
144
+ continue;
145
+ const empty = Array.isArray(v) ? v.length === 0 : isObj(v) && Object.keys(v).length === 0;
146
+ if (!empty) {
147
+ errs.push(`package.json ${f} is not allowed: bundle third-party dependencies into the entry`);
148
+ }
149
+ }
150
+ let peers = {};
151
+ if (pkg.peerDependencies !== undefined) {
152
+ if (!isObj(pkg.peerDependencies))
153
+ errs.push("package.json peerDependencies must be an object");
154
+ else {
155
+ peers = pkg.peerDependencies;
156
+ for (const k of Object.keys(peers)) {
157
+ if (!HOST_PROVIDED_PACKAGES.includes(k)) {
158
+ errs.push(`package.json peerDependencies "${k}" is not provided by the host (only ${HOST_PROVIDED_PACKAGES.join(", ")})`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ const zodRange = peers.zod;
164
+ if (zodRange !== undefined) {
165
+ if (typeof zodRange !== "string" || semver.validRange(zodRange) === null) {
166
+ errs.push("package.json peerDependencies zod must be a valid semver range");
167
+ }
168
+ else if (opts.hostZodVersion !== undefined &&
169
+ !semver.satisfies(opts.hostZodVersion, zodRange, { includePrerelease: true })) {
170
+ errs.push(`package.json peerDependencies zod "${zodRange}" is not satisfied by the host zod ${opts.hostZodVersion}`);
171
+ }
172
+ }
173
+ return peers;
174
+ }
175
+ /**
176
+ * Validates the `package.json` of a plugin tgz against its parsed `.capybara-plugin/plugin.json`
177
+ * ({@link PluginManifest}), returning the problems (empty when valid): the rules shared with every
178
+ * plugin package (npm name, semver version, no install scripts, no `gypfile`, no dependency fields,
179
+ * `peerDependencies` only host packages, a valid `zod` peer range satisfied by `hostZodVersion`),
180
+ * no `capybara` section, the version equal to the manifest's, and:
181
+ * - with an extension: `peerDependencies["@capybara-hq/plugin-sdk"]` present and equal to
182
+ * `extension.manifest.sdk`, which the host SDK version satisfies (includePrerelease), and
183
+ * `main` / `exports` absent or naming `extension.entry` (the packer writes both);
184
+ * - without one: no `peerDependencies`, `main` or `exports` at all.
185
+ */
186
+ export function validatePluginPackageJsonFor(pkg, manifest, hostSdkVersion, opts = {}) {
187
+ if (!isObj(pkg))
188
+ return ["package.json must be a JSON object"];
189
+ const errs = [];
190
+ const peers = checkPluginPackageBasics(pkg, opts, errs);
191
+ if (pkg.capybara !== undefined) {
192
+ errs.push("package.json must not have a capybara section (the manifest is .capybara-plugin/plugin.json)");
193
+ }
194
+ if (typeof pkg.version === "string" && pkg.version !== manifest.version) {
195
+ errs.push(`package.json version "${pkg.version}" does not match plugin.json version "${manifest.version}"`);
196
+ }
197
+ const ext = manifest.extension;
198
+ if (ext === undefined) {
199
+ if (isObj(pkg.peerDependencies) && Object.keys(pkg.peerDependencies).length > 0) {
200
+ errs.push("package.json peerDependencies is not allowed in a plugin without an extension (nothing is loaded from the host)");
201
+ }
202
+ for (const f of ["main", "exports"]) {
203
+ if (pkg[f] !== undefined) {
204
+ errs.push(`package.json ${f} is not allowed in a plugin without an extension (there is no module to load)`);
205
+ }
206
+ }
207
+ return errs;
208
+ }
209
+ errs.push(...entryFieldIssues(pkg, ext.entry));
210
+ const sdk = ext.manifest.sdk;
211
+ const peerSdk = peers["@capybara-hq/plugin-sdk"];
212
+ if (peerSdk === undefined) {
213
+ errs.push(`package.json peerDependencies "@capybara-hq/plugin-sdk" is required and must equal extension.manifest.sdk "${sdk}"`);
214
+ }
215
+ else if (peerSdk !== sdk) {
216
+ errs.push(`package.json peerDependencies "@capybara-hq/plugin-sdk" "${String(peerSdk)}" must equal extension.manifest.sdk "${sdk}"`);
217
+ }
218
+ const sdkProblem = hostSdkProblem(sdk, hostSdkVersion);
219
+ if (sdkProblem !== undefined)
220
+ errs.push(sdkProblem);
221
+ return errs;
222
+ }
223
+ /**
224
+ * `main` and `exports` of an extension-bearing plugin: absent, or naming the extension entry
225
+ * (`"main": "<entry>"`, `"exports": "<entry>"` or `{ ".": "<entry>" }`), so the package resolves by
226
+ * name (`require.resolve`, a test loading the extension by package name) to the module the kernel
227
+ * loads, and to nothing else.
228
+ */
229
+ function entryFieldIssues(pkg, entry) {
230
+ const errs = [];
231
+ const why = `must be absent or name the extension entry "${entry}"`;
232
+ // `main` may omit the leading ./ (`dist/index.mjs`); an `exports` target must keep it (Node)
233
+ const bare = (p) => (typeof p === "string" ? p.replace(/^\.\//, "") : p);
234
+ if (pkg.main !== undefined && bare(pkg.main) !== bare(entry)) {
235
+ errs.push(`package.json main ${why}`);
236
+ }
237
+ const ex = pkg.exports;
238
+ const exportsOk = ex === undefined ||
239
+ ex === entry ||
240
+ (isObj(ex) && Object.keys(ex).length === 1 && ex["."] === entry);
241
+ if (!exportsOk)
242
+ errs.push(`package.json exports ${why} (as a string or { ".": "${entry}" })`);
243
+ return errs;
244
+ }
245
+ /**
246
+ * Whether the host SDK satisfies an extension's `extension.manifest.sdk` range: the problem
247
+ * (English), or undefined when it does (an invalid range is reported by the manifest parser).
248
+ */
249
+ export function hostSdkProblem(sdk, hostSdkVersion) {
250
+ if (semver.validRange(sdk) === null)
251
+ return undefined;
252
+ if (semver.satisfies(hostSdkVersion, sdk, { includePrerelease: true }))
253
+ return undefined;
254
+ return `extension.manifest.sdk "${sdk}" is not satisfied by the host SDK version ${hostSdkVersion}`;
255
+ }
256
+ /** 连接器模板与目录段的上限。 */
257
+ export const CONNECTOR_TEMPLATE_LIMITS = {
258
+ /** 图标字节数上限。 */
259
+ iconBytes: 64 * 1024,
260
+ /** 端点预设 JSON 序列化后的字符数上限。 */
261
+ endpointChars: 16 * 1024,
262
+ /** `editableEndpoint` 条数上限。 */
263
+ editablePaths: 50,
264
+ /** `defaultToolPolicies` 条数上限。 */
265
+ toolPolicies: 1000,
266
+ /** publisher 字符数上限。 */
267
+ publisherChars: 100,
268
+ /** description 字符数上限。 */
269
+ descriptionChars: 1024,
270
+ /** displayName 每种语言的字符数上限。 */
271
+ displayNameChars: 100,
272
+ };
273
+ /** 目录条目名(与清单条目名同一规则):小写字母或数字开头,之后小写字母、数字、连字符,≤ 64。 */
274
+ export const CATALOG_ITEM_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
275
+ /** An exact semver version (a regex JSON Schema can use). */
276
+ export const SEMVER_VERSION_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
277
+ /** The sha512 SRI of an npm package tgz: `sha512-<86 base64 chars>==`. */
278
+ export const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
279
+ const PATH_SEG_RE = /^[A-Za-z0-9_-]{1,100}$/;
280
+ const PROTO_KEYS = new Set(["__proto__", "prototype", "constructor"]);
281
+ /**
282
+ * 凭据样式的字段名(比较时转小写、去掉 `-` 与 `_`):模板会被所有组织看到,端点预设里出现这些键一律拒绝。
283
+ * `authorization` / `cookie` 等头名同样在列(端点头里的凭据)。
284
+ */
285
+ const CREDENTIAL_KEYS = new Set([
286
+ "token",
287
+ "accesstoken",
288
+ "refreshtoken",
289
+ "idtoken",
290
+ "bearertoken",
291
+ "authtoken",
292
+ "apikey",
293
+ "apitoken",
294
+ "xapikey",
295
+ "secret",
296
+ "clientsecret",
297
+ "password",
298
+ "passwd",
299
+ "credential",
300
+ "credentials",
301
+ "privatekey",
302
+ "authorization",
303
+ "proxyauthorization",
304
+ "cookie",
305
+ "sessiontoken",
306
+ ]);
307
+ const normKey = (k) => k.toLowerCase().replace(/[-_]/g, "");
308
+ /** 递归找出凭据样式的键(只报路径,不回显值)。 */
309
+ function credentialKeyPaths(v, at, out, depth = 0) {
310
+ if (depth > 20)
311
+ return;
312
+ if (Array.isArray(v)) {
313
+ v.forEach((x, i) => {
314
+ credentialKeyPaths(x, `${at}.${i}`, out, depth + 1);
315
+ });
316
+ return;
317
+ }
318
+ if (!isObj(v))
319
+ return;
320
+ for (const [k, x] of Object.entries(v)) {
321
+ const p = `${at}.${k}`;
322
+ if (CREDENTIAL_KEYS.has(normKey(k)))
323
+ out.push(p);
324
+ credentialKeyPaths(x, p, out, depth + 1);
325
+ }
326
+ }
327
+ const TEMPLATE_KEYS = new Set([
328
+ "plugin",
329
+ "pluginVersion",
330
+ "connector",
331
+ "endpoint",
332
+ "editableEndpoint",
333
+ "auth",
334
+ "ownership",
335
+ "defaultToolPolicies",
336
+ "icon",
337
+ ]);
338
+ /**
339
+ * 校验连接器模板({@link ConnectorTemplateSpec})的静态部分,返回问题列表(空表示合法;路径相对模板根,不回显值):
340
+ * 字段齐全、无未知字段;`auth` 符合 {@link ConnectorAuthSchema}(strict:夹带 `token` 等多余字段即拒绝);
341
+ * 端点预设里任何层级都不许有凭据样式的键;`editableEndpoint` 是不重复的点路径;`ownership` 与 `auth.ownership`
342
+ * 一致;默认策略只许 allow / ask / deny;icon 是包内 .svg / .png 路径。
343
+ * 端点是否符合连接器的 `endpointSchema`、`editableEndpoint` 是否存在于其中,由内核在导入时按已加载的连接器校验。
344
+ */
345
+ export function validateConnectorTemplate(t) {
346
+ if (!isObj(t))
347
+ return ["template must be an object"];
348
+ const errs = [];
349
+ for (const k of Object.keys(t)) {
350
+ if (!TEMPLATE_KEYS.has(k))
351
+ errs.push(`template has unknown field "${k}"`);
352
+ }
353
+ if (typeof t.plugin !== "string" || !EXTENSION_NAME_RE.test(t.plugin)) {
354
+ errs.push(`template.plugin must be a plugin name matching ${EXTENSION_NAME_RE}`);
355
+ }
356
+ if (t.pluginVersion !== undefined &&
357
+ (typeof t.pluginVersion !== "string" || semver.validRange(t.pluginVersion) === null)) {
358
+ errs.push("template.pluginVersion must be a valid semver range");
359
+ }
360
+ if (typeof t.connector !== "string" || t.connector.length < 1 || t.connector.length > 100) {
361
+ errs.push("template.connector must be a connector kind (1-100 characters)");
362
+ }
363
+ if (!isObj(t.endpoint))
364
+ errs.push("template.endpoint must be an object");
365
+ else {
366
+ if (JSON.stringify(t.endpoint).length > CONNECTOR_TEMPLATE_LIMITS.endpointChars) {
367
+ errs.push(`template.endpoint is larger than ${CONNECTOR_TEMPLATE_LIMITS.endpointChars} characters`);
368
+ }
369
+ const leaks = [];
370
+ credentialKeyPaths(t.endpoint, "template.endpoint", leaks);
371
+ for (const p of leaks) {
372
+ errs.push(`${p} looks like a credential: templates must not contain credentials; organizations provide them when installing`);
373
+ }
374
+ }
375
+ if (t.editableEndpoint !== undefined) {
376
+ if (!Array.isArray(t.editableEndpoint) ||
377
+ t.editableEndpoint.length > CONNECTOR_TEMPLATE_LIMITS.editablePaths) {
378
+ errs.push(`template.editableEndpoint must be an array of at most ${CONNECTOR_TEMPLATE_LIMITS.editablePaths} paths`);
379
+ }
380
+ else {
381
+ const seen = new Set();
382
+ for (const p of t.editableEndpoint) {
383
+ const ok = typeof p === "string" &&
384
+ p.split(".").every((s) => PATH_SEG_RE.test(s) && !PROTO_KEYS.has(s));
385
+ if (!ok) {
386
+ errs.push(`template.editableEndpoint entries must be dot paths of letters, digits, "_" and "-" (got ${JSON.stringify(typeof p === "string" ? p.slice(0, 100) : typeof p)})`);
387
+ continue;
388
+ }
389
+ if (p.split(".").some((seg) => CREDENTIAL_KEYS.has(normKey(seg)))) {
390
+ errs.push(`template.editableEndpoint "${p}" looks like a credential field: organizations provide credentials through the connector's auth, not the endpoint`);
391
+ continue;
392
+ }
393
+ if (seen.has(p))
394
+ errs.push(`template.editableEndpoint has duplicate path "${p}"`);
395
+ seen.add(p);
396
+ }
397
+ }
398
+ }
399
+ const auth = ConnectorAuthSchema.safeParse(t.auth);
400
+ if (!auth.success) {
401
+ for (const i of auth.error.issues) {
402
+ const path = ["template.auth", ...i.path.map(String)].join(".");
403
+ const keys = i.keys;
404
+ errs.push(keys && keys.length > 0
405
+ ? `${path} has unknown field(s) ${keys.map((k) => `"${k}"`).join(", ")}: auth presets must not contain credentials`
406
+ : `${path}: ${i.message}`);
407
+ }
408
+ }
409
+ if (t.ownership !== undefined) {
410
+ if (t.ownership !== "org" && t.ownership !== "personal") {
411
+ errs.push('template.ownership must be "org" or "personal"');
412
+ }
413
+ else if (auth.success) {
414
+ const a = auth.data;
415
+ if (a.type === "none") {
416
+ errs.push("template.ownership is not allowed with auth type none (no credential)");
417
+ }
418
+ else if (a.ownership !== t.ownership) {
419
+ errs.push(`template.ownership "${t.ownership}" does not match template.auth.ownership "${a.ownership}"`);
420
+ }
421
+ }
422
+ }
423
+ if (t.defaultToolPolicies !== undefined) {
424
+ const p = t.defaultToolPolicies;
425
+ if (!isObj(p) || Object.keys(p).length > CONNECTOR_TEMPLATE_LIMITS.toolPolicies) {
426
+ errs.push(`template.defaultToolPolicies must be an object with at most ${CONNECTOR_TEMPLATE_LIMITS.toolPolicies} entries`);
427
+ }
428
+ else {
429
+ for (const [name, v] of Object.entries(p)) {
430
+ if (name.length < 1 || name.length > 200) {
431
+ errs.push("template.defaultToolPolicies keys must be tool names (1-200 characters)");
432
+ }
433
+ else if (PROTO_KEYS.has(name)) {
434
+ errs.push(`template.defaultToolPolicies key "${name}" is not allowed`);
435
+ }
436
+ else if (v !== "allow" && v !== "ask" && v !== "deny") {
437
+ errs.push(`template.defaultToolPolicies.${name} must be "allow", "ask" or "deny"`);
438
+ }
439
+ }
440
+ }
441
+ }
442
+ if (t.icon !== undefined) {
443
+ if (typeof t.icon !== "string" || !isSafePackagePath(t.icon) || !/\.(svg|png)$/i.test(t.icon)) {
444
+ errs.push("template.icon must be a relative .svg or .png path inside the package");
445
+ }
446
+ }
447
+ return errs;
448
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Sandbox contract `sandbox@1`: a single-select, brokered extension point (extensions may not
3
+ * `requires` it; the kernel's `SandboxService` consumes it). The provider implements only the
4
+ * primitives below; the kernel builds `glob` / `grep` / `edit` from `exec` plus `readFile` /
5
+ * `writeFile`.
6
+ */
7
+ /** Sandbox extension point ref. */
8
+ export declare const SANDBOX_EXTENSION_POINT = "sandbox@1";
9
+ /** 沙箱内允许模型访问的根目录。 */
10
+ export declare const SANDBOX_ROOTS: readonly ["/workspace", "/skills", "/tmp"];
11
+ /** 沙箱资源与出站限制。 */
12
+ export interface SandboxLimits {
13
+ cpu: number;
14
+ memoryMb: number;
15
+ diskMb: number;
16
+ egress: "none" | "allowlist" | "open";
17
+ /** 出站白名单(`egress` 为 `allowlist` 时生效);每项须通过 {@link validateAllowHost}。 */
18
+ allowHosts: string[];
19
+ }
20
+ /** 提供方的实例句柄。 */
21
+ export interface SandboxHandle {
22
+ /** 提供方的实例 id。 */
23
+ id: string;
24
+ }
25
+ /** 命令执行结果。 */
26
+ export interface ExecResult {
27
+ /** 超时或在执行中被中止为 null(此时 `timedOut` 分别为 true / false)。 */
28
+ exitCode: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ truncated: boolean;
32
+ timedOut: boolean;
33
+ }
34
+ /**
35
+ * 实例在提供方那边确定已经不在了(容器被删、E2B 沙箱过期)。`exec` / `readFile` / `writeFile` / `stat`
36
+ * 发现实例不在时必须抛它(而不是普通 Error),内核据此重建沙箱并重试一次;只是暂时连不上时抛普通错误。
37
+ */
38
+ export declare class SandboxGoneError extends Error {
39
+ readonly sandboxGone: true;
40
+ constructor(message: string, opts?: {
41
+ cause?: unknown;
42
+ });
43
+ }
44
+ /** 鸭子类型判断(不用 instanceof),兼容插件自带另一份 plugin-sdk 副本的情况。 */
45
+ export declare function isSandboxGoneError(e: unknown): e is SandboxGoneError;
46
+ /**
47
+ * A holder tag (`exec` argument `tag`, {@link SandboxProvider.killTagged}): the kernel passes its
48
+ * lease tokens (UUIDs). Providers check tags against it before putting them in a command line or a
49
+ * kill pattern.
50
+ */
51
+ export declare const SANDBOX_TAG_RE: RegExp;
52
+ /**
53
+ * `sandbox@1` 的实现(由沙箱插件提供,如 Docker、E2B)。
54
+ * 实例确定不在时:`resume` 返回 `gone`;`exec` / `readFile` / `writeFile` / `stat` 抛 {@link SandboxGoneError};
55
+ * `suspend` / `destroy` 照常成功。
56
+ *
57
+ * **自动启动**:实例还在、但已停止 / 暂停(被内核之外的原因停掉:宿主机重启、守护进程重启、沙箱里的进程把
58
+ * 自己的 init 弄停了、休眠扫描停了实例却没来得及记下 `suspended`……)时,`exec` / `readFile` / `writeFile` /
59
+ * `stat` 必须先透明地把它启动起来(同 `resume`,文件保留)再执行,而不是报错——内核只信自己记的 `running`。
60
+ *
61
+ * **命令时限在沙箱里也要生效**:`exec` 除了在提供方这边按 `timeoutMs` 停掉命令,还必须让沙箱里的命令自己在
62
+ * `timeoutMs` 之后不久(几秒的余量)被杀掉。内核副本崩溃时提供方这边的计时随之消失,断开连接也不会结束命令;
63
+ * 沙箱里的时限保证它不会无限期地与下一个持锁方的命令同时运行。
64
+ *
65
+ * **中止**:每个方法都必须及时响应 `signal`(已中止就立即失败,进行中被中止就尽快以中止原因结束)——内核在锁丢失、
66
+ * 运行被中止或超时时靠它停下,不响应就可能与新的持锁方同时操作同一实例。`exec` 被中止时必须杀掉正在跑的命令
67
+ * (连同它派生的进程),而不只是不再等待它的输出;命令启动后才被中止的 `exec` 以 `exitCode: null`、
68
+ * `timedOut: false` 正常 resolve(带上已收集的输出),不 reject。调用时 signal 已中止则立即 reject。
69
+ *
70
+ * **Concurrent use (optional)**: a provider that declares {@link SandboxProvider.concurrentExec}
71
+ * lets the kernel call `exec`, `readFile`, `writeFile` and `stat` concurrently on one instance
72
+ * (commands of parallel sub-agents sharing their parent conversation's sandbox). Then those four
73
+ * methods must be safe to call concurrently, and **auto-start must be idempotent under concurrent
74
+ * calls**: two calls that both find the instance stopped both start it without failing. Each
75
+ * `exec` labels its command with its `tag`, and an aborted `exec` kills only its own command.
76
+ * `concurrentExec` requires {@link SandboxProvider.killTagged}; a provider that declares one
77
+ * without the other, or neither, is used one call at a time (the behavior without them).
78
+ */
79
+ export interface SandboxProvider {
80
+ /**
81
+ * Declares that `exec` / `readFile` / `writeFile` / `stat` may run concurrently on one instance
82
+ * (see the contract above). Requires {@link SandboxProvider.killTagged}.
83
+ */
84
+ readonly concurrentExec?: boolean;
85
+ /** `name` 形如 `cap-<conversationId>`。 */
86
+ create(args: {
87
+ name: string;
88
+ image: string;
89
+ limits: SandboxLimits;
90
+ signal: AbortSignal;
91
+ }): Promise<SandboxHandle>;
92
+ /** `gone` 表示提供方那边已经没有这个实例。 */
93
+ resume(h: SandboxHandle, signal: AbortSignal): Promise<"resumed" | "gone">;
94
+ /** 文件必须保留。实例不存在也算成功。 */
95
+ suspend(h: SandboxHandle, signal: AbortSignal): Promise<void>;
96
+ /** 不存在也算成功。 */
97
+ destroy(h: SandboxHandle, signal: AbortSignal): Promise<void>;
98
+ /**
99
+ * 可选:按 `create` 时的 `name`(`cap-<conversationId>`)销毁实例,不存在也算成功。
100
+ * 内核在拿不到句柄时用它清理(例如 `create` 已在提供方那边完成、内核却在记下句柄前退出)。
101
+ */
102
+ destroyByName?(name: string, signal: AbortSignal): Promise<void>;
103
+ /**
104
+ * 可选:杀掉这个实例里经 `exec` 启动、仍在运行的全部命令(连同它们派生的、没有自己另起会话的进程)。
105
+ * 内核在接手一个前任持锁方没有正常结束的沙箱(前任崩溃)时、第一次执行前调用,清掉前任遗留的命令;
106
+ * 命令自己放到后台、另起会话的进程不保证被杀。实例已停止时同样先启动;不在时抛 {@link SandboxGoneError}。
107
+ */
108
+ killAll?(h: SandboxHandle, signal: AbortSignal): Promise<void>;
109
+ /**
110
+ * Optional: kill every running `exec` command labelled with one of `tags` (and its process
111
+ * tree), leaving other commands running. The kernel calls it for the tags of holders that
112
+ * crashed while live holders keep running commands. Tags match {@link SANDBOX_TAG_RE}. Match
113
+ * whole tags: one tag may be a prefix of another (`ab` of `ab-cd`), and killing `ab` must not
114
+ * touch `ab-cd`'s commands. Starts a stopped instance first; throws {@link SandboxGoneError}
115
+ * when the instance is gone.
116
+ */
117
+ killTagged?(h: SandboxHandle, tags: string[], signal: AbortSignal): Promise<void>;
118
+ /**
119
+ * 执行一条命令。`maxOutputBytes` 是 stdout 与 stderr **合计**最多收集的字节,超出的丢弃、`truncated` 为 true。
120
+ */
121
+ exec(h: SandboxHandle, args: {
122
+ command: string;
123
+ cwd?: string;
124
+ env?: Record<string, string>;
125
+ timeoutMs: number;
126
+ maxOutputBytes: number;
127
+ signal: AbortSignal;
128
+ /**
129
+ * The holder's tag ({@link SANDBOX_TAG_RE}); label the command with it so
130
+ * {@link SandboxProvider.killTagged} can find it. Absent from kernels before phase C.
131
+ */
132
+ tag?: string;
133
+ }): Promise<ExecResult>;
134
+ /** 读文件(最多 `maxBytes` 字节,更长的截断、`truncated` 为 true)。不存在、或是目录时返回 null。 */
135
+ readFile(h: SandboxHandle, path: string, opts: {
136
+ maxBytes: number;
137
+ signal: AbortSignal;
138
+ }): Promise<{
139
+ data: Uint8Array;
140
+ truncated: boolean;
141
+ } | null>;
142
+ /** 父目录自动创建。 */
143
+ writeFile(h: SandboxHandle, path: string, data: Uint8Array, signal: AbortSignal): Promise<void>;
144
+ stat(h: SandboxHandle, path: string, signal: AbortSignal): Promise<{
145
+ size: number;
146
+ isDir: boolean;
147
+ } | null>;
148
+ }
149
+ /** 校验沙箱内路径:返回错误列表,空数组表示合法。 */
150
+ export declare function validateSandboxPath(p: string): string[];
151
+ /** 校验出站白名单主机名:小写、无协议与端口、可选前导 `*.`、IDN 只认 punycode。 */
152
+ export declare function validateAllowHost(h: string): string[];