@capybara-hq/plugin-sdk 0.1.0-rc.5 → 0.1.0-rc.7
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/agent-docs/connector-templates.md +1 -0
- package/agent-docs/plugin-format.md +11 -0
- package/dist/cards.d.ts +7 -0
- package/dist/cards.js +4 -4
- package/dist/channel.d.ts +20 -1
- package/dist/connector-auth.d.ts +2 -0
- package/dist/connector-auth.js +15 -0
- package/dist/device-protocol.d.ts +118 -0
- package/dist/device-protocol.js +276 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/marketplace.d.ts +26 -0
- package/dist/marketplace.js +43 -1
- package/dist/plugin-manifest.d.ts +12 -0
- package/dist/plugin-manifest.js +61 -0
- package/dist/sandbox.d.ts +24 -2
- package/dist/sandbox.js +41 -8
- package/dist/setup-guide.d.ts +94 -0
- package/dist/setup-guide.js +135 -0
- package/dist/skill-invocation.d.ts +4 -3
- package/dist/skill-invocation.js +5 -4
- package/dist/skill.d.ts +7 -4
- package/dist/skill.js +9 -6
- package/package.json +1 -1
- package/schemas/connector-template.v1.schema.json +3 -0
- package/schemas/marketplace.v1.schema.json +14 -0
- package/schemas/plugin.v1.schema.json +136 -0
package/dist/marketplace.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { CATALOG_ITEM_NAME_RE, isSafePackagePath, NPM_PACKAGE_NAME_RE, SEMVER_VERSION_RE, SHA512_INTEGRITY_RE, } from "./plugin-package.js";
|
|
2
|
+
import { CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, isSafePackagePath, NPM_PACKAGE_NAME_RE, SEMVER_VERSION_RE, SHA512_INTEGRITY_RE, } from "./plugin-package.js";
|
|
3
3
|
/**
|
|
4
4
|
* The marketplace manifest `marketplace.json` (package ecosystem spec §5.2): a list of plugins,
|
|
5
5
|
* each with one source. One entry is one version. `capybara-plugin marketplace --write` lists one
|
|
@@ -29,6 +29,8 @@ export const MARKETPLACE_LIMITS = {
|
|
|
29
29
|
plugins: 5000,
|
|
30
30
|
permissionKeys: 500,
|
|
31
31
|
urlChars: 2000,
|
|
32
|
+
/** Bytes of an entry icon (the plugin icon limit, {@link CONNECTOR_TEMPLATE_LIMITS}). */
|
|
33
|
+
iconBytes: CONNECTOR_TEMPLATE_LIMITS.iconBytes,
|
|
32
34
|
};
|
|
33
35
|
const M = MARKETPLACE_LIMITS;
|
|
34
36
|
const MAX_ERRORS = 50;
|
|
@@ -37,6 +39,13 @@ const PERMISSION_KEY_RE = /^[a-z][a-z.]{0,62}(?::[^\r\n]{1,256})?$/;
|
|
|
37
39
|
const SHA256_INTEGRITY_RE = /^sha256-[A-Za-z0-9+/]{43}=$/;
|
|
38
40
|
const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
39
41
|
const HTTPS_URL_RE = /^https:\/\/\S+$/;
|
|
42
|
+
/** An entry icon's file extension (of the URL's path, or of the repository path). */
|
|
43
|
+
export const MARKETPLACE_ICON_RE = /\.(?:svg|png)$/i;
|
|
44
|
+
// Spelled without the `i` flag so the generated JSON Schema keeps the rule
|
|
45
|
+
const ICON_EXT = "\\.(?:[sS][vV][gG]|[pP][nN][gG])";
|
|
46
|
+
const ICON_URL_RE = new RegExp(`^https://[^\\s?#]+${ICON_EXT}(?:\\?[^\\s#]*)?$`);
|
|
47
|
+
const ICON_URL_OR_LOOPBACK_RE = new RegExp(`^(?:https://[^\\s?#]+|http://(?:127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|localhost|\\[::1\\])(?::\\d{1,5})?/[^\\s?#]*)${ICON_EXT}(?:\\?[^\\s#]*)?$`);
|
|
48
|
+
const ICON_PATH_RE = new RegExp(`^\\./[^\\s?#]+${ICON_EXT}$`);
|
|
40
49
|
const HTTPS_OR_LOOPBACK_RE = /^(?:https:\/\/\S+|http:\/\/(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(?::\d{1,5})?(?:\/\S*)?)$/;
|
|
41
50
|
const urlString = (re) => z
|
|
42
51
|
.string()
|
|
@@ -55,6 +64,15 @@ const exactVersion = (what) => z
|
|
|
55
64
|
.string()
|
|
56
65
|
.max(256)
|
|
57
66
|
.regex(SEMVER_VERSION_RE, { message: `${what} must be an exact semver version` });
|
|
67
|
+
/** The path of a URL (empty when it does not parse). */
|
|
68
|
+
const urlPathname = (s) => {
|
|
69
|
+
try {
|
|
70
|
+
return new URL(s).pathname;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
58
76
|
/** `./<safe path>` inside the marketplace repo. */
|
|
59
77
|
const repoPath = z
|
|
60
78
|
.string()
|
|
@@ -144,6 +162,12 @@ function buildSchema(opts) {
|
|
|
144
162
|
})
|
|
145
163
|
.optional(),
|
|
146
164
|
homepage: urlString(HTTPS_URL_RE).optional(),
|
|
165
|
+
icon: z
|
|
166
|
+
.union([
|
|
167
|
+
urlString(opts.allowInsecureLoopback ? ICON_URL_OR_LOOPBACK_RE : ICON_URL_RE).refine((s) => MARKETPLACE_ICON_RE.test(urlPathname(s)), { message: "an icon URL's path must end in .svg or .png" }),
|
|
168
|
+
repoPath.regex(ICON_PATH_RE, { message: "icon must be a .svg or .png file" }),
|
|
169
|
+
])
|
|
170
|
+
.optional(),
|
|
147
171
|
deprecated: z
|
|
148
172
|
.union([
|
|
149
173
|
z.boolean(),
|
|
@@ -248,6 +272,14 @@ const SOURCE_FORMS = ["path", "git", "npm", "archive"];
|
|
|
248
272
|
* form the source uses instead, or that it must use exactly one form.
|
|
249
273
|
*/
|
|
250
274
|
function explainSource(json, issue) {
|
|
275
|
+
if (issue.code === "invalid_union" && issue.path[issue.path.length - 1] === "icon") {
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
path: issue.path,
|
|
279
|
+
message: 'icon must be an https URL whose path ends in .svg or .png, or a "./" path inside the repository ending in .svg or .png',
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
}
|
|
251
283
|
if (issue.code !== "invalid_union" || issue.path[issue.path.length - 1] !== "source") {
|
|
252
284
|
return [issue];
|
|
253
285
|
}
|
|
@@ -272,6 +304,16 @@ export function marketplaceEntryVersion(e) {
|
|
|
272
304
|
return e.source.npm.version;
|
|
273
305
|
return null;
|
|
274
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The entry's icon (see {@link MarketplaceEntry.icon}): `{ url }` for an absolute URL, `{ path }`
|
|
309
|
+
* (normalized, without the leading `./`) for a path inside the marketplace's repository, `null`
|
|
310
|
+
* without one.
|
|
311
|
+
*/
|
|
312
|
+
export function marketplaceEntryIcon(e) {
|
|
313
|
+
if (e.icon === undefined)
|
|
314
|
+
return null;
|
|
315
|
+
return e.icon.startsWith("./") ? { path: e.icon.slice(2) } : { url: e.icon };
|
|
316
|
+
}
|
|
275
317
|
/**
|
|
276
318
|
* The entry's deprecation (see {@link MarketplaceEntry.deprecated}): `null` when it is not
|
|
277
319
|
* deprecated (absent or `false`), else its reason (`null` without one).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type JsonSchema } from "./config-ui.js";
|
|
3
3
|
import { type ConnectorTemplateSpec, type LocalizedName, type StaticExtensionManifest } from "./plugin-package.js";
|
|
4
|
+
import { type PluginSetupGuide } from "./setup-guide.js";
|
|
4
5
|
/**
|
|
5
6
|
* The plugin manifest `.capybara-plugin/plugin.json` (package ecosystem spec §4.1). A plugin is a
|
|
6
7
|
* bundle of at most one extension, skills and connector templates, packed as an npm-pack tgz:
|
|
@@ -123,6 +124,11 @@ export interface PluginManifest {
|
|
|
123
124
|
connectorTemplates?: PluginConnectorTemplateRef[];
|
|
124
125
|
/** Agent templates; omitted = scan `agents/*.md`. */
|
|
125
126
|
agents?: PluginAgentRef[];
|
|
127
|
+
/**
|
|
128
|
+
* Setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): steps and template files the
|
|
129
|
+
* kernel renders for a place (`channel-connection`); see {@link PluginSetupGuide}.
|
|
130
|
+
*/
|
|
131
|
+
setupGuides?: PluginSetupGuide[];
|
|
126
132
|
extension?: PluginExtensionSection;
|
|
127
133
|
}
|
|
128
134
|
/** A connector template file `connectors/<id>.json`; the template id is the file's base name. */
|
|
@@ -131,6 +137,12 @@ export interface ConnectorTemplateFile {
|
|
|
131
137
|
description?: string;
|
|
132
138
|
/** Safe path relative to the plugin root (an optional leading `./`), .svg / .png. */
|
|
133
139
|
icon?: string;
|
|
140
|
+
/**
|
|
141
|
+
* `false`: the template starts switched off in every org, even where its plugin is on, until an
|
|
142
|
+
* org admin switches it on (it then needs no org input while off). Default `true`: on wherever
|
|
143
|
+
* its plugin is. Ignored for a builtin plugin.
|
|
144
|
+
*/
|
|
145
|
+
defaultEnabled?: boolean;
|
|
134
146
|
/** Validated by {@link validateConnectorTemplate}. */
|
|
135
147
|
template: ConnectorTemplateSpec;
|
|
136
148
|
}
|
package/dist/plugin-manifest.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIG_UI_LIMITS } from "./config-ui.js";
|
|
|
5
5
|
import { defineExtension, EXTENSION_NAME_RE } from "./define-extension.js";
|
|
6
6
|
import { CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, isSafePackagePath, SEMVER_VERSION_RE, validateConnectorTemplate, } from "./plugin-package.js";
|
|
7
7
|
import { hasUpperBound } from "./semver-range.js";
|
|
8
|
+
import { SETUP_GUIDE_FILE_RE, SETUP_GUIDE_LANGUAGES, SETUP_GUIDE_LIMITS, SETUP_GUIDE_MODES, SETUP_GUIDE_PLACES, } from "./setup-guide.js";
|
|
8
9
|
/**
|
|
9
10
|
* The plugin manifest `.capybara-plugin/plugin.json` (package ecosystem spec §4.1). A plugin is a
|
|
10
11
|
* bundle of at most one extension, skills and connector templates, packed as an npm-pack tgz:
|
|
@@ -97,6 +98,33 @@ const configSchemaJson = z
|
|
|
97
98
|
.refine((v) => new TextEncoder().encode(JSON.stringify(v)).length <= CONFIG_UI_LIMITS.configSchemaBytes, {
|
|
98
99
|
message: `must be at most ${CONFIG_UI_LIMITS.configSchemaBytes} bytes of JSON (${CONFIG_UI_LIMITS.configSchemaBytes / 1024} KiB)`,
|
|
99
100
|
});
|
|
101
|
+
const guideText = (max, what) => z.strictObject({ en: text(max, `${what}.en`), zh: text(max, `${what}.zh`).optional() });
|
|
102
|
+
const setupGuide = z.strictObject({
|
|
103
|
+
id: z.string().regex(CATALOG_ITEM_NAME_RE, {
|
|
104
|
+
message: "id must match ^[a-z0-9][a-z0-9-]{0,63}$",
|
|
105
|
+
}),
|
|
106
|
+
place: z.enum(SETUP_GUIDE_PLACES),
|
|
107
|
+
title: guideText(SETUP_GUIDE_LIMITS.titleChars, "title"),
|
|
108
|
+
steps: z
|
|
109
|
+
.array(z.strictObject({
|
|
110
|
+
en: text(SETUP_GUIDE_LIMITS.stepChars, "en"),
|
|
111
|
+
zh: text(SETUP_GUIDE_LIMITS.stepChars, "zh").optional(),
|
|
112
|
+
mode: z.string().max(64).optional(),
|
|
113
|
+
}))
|
|
114
|
+
.max(SETUP_GUIDE_LIMITS.steps)
|
|
115
|
+
.optional(),
|
|
116
|
+
files: z
|
|
117
|
+
.array(z.strictObject({
|
|
118
|
+
path: dotPath("path").regex(SETUP_GUIDE_FILE_RE, {
|
|
119
|
+
message: "path must be a .tmpl, .yaml, .yml, .json or .txt file",
|
|
120
|
+
}),
|
|
121
|
+
title: guideText(SETUP_GUIDE_LIMITS.titleChars, "title"),
|
|
122
|
+
language: z.enum(SETUP_GUIDE_LANGUAGES),
|
|
123
|
+
mode: z.string().max(64).optional(),
|
|
124
|
+
}))
|
|
125
|
+
.max(SETUP_GUIDE_LIMITS.files)
|
|
126
|
+
.optional(),
|
|
127
|
+
});
|
|
100
128
|
const extensionSection = z.strictObject({
|
|
101
129
|
entry: dotPath("entry").refine((p) => p.endsWith(".mjs"), {
|
|
102
130
|
message: "entry must be a .mjs file",
|
|
@@ -168,6 +196,7 @@ const schema = z
|
|
|
168
196
|
}))
|
|
169
197
|
.max(L.agents)
|
|
170
198
|
.optional(),
|
|
199
|
+
setupGuides: z.array(setupGuide).max(SETUP_GUIDE_LIMITS.guides).optional(),
|
|
171
200
|
extension: extensionSection.optional(),
|
|
172
201
|
})
|
|
173
202
|
.superRefine((m, ctx) => {
|
|
@@ -206,6 +235,37 @@ const schema = z
|
|
|
206
235
|
issue(["agents", i, "path"], `duplicate agent template path ${a.path}`);
|
|
207
236
|
agentPaths.add(a.path);
|
|
208
237
|
});
|
|
238
|
+
const guideIds = new Set();
|
|
239
|
+
m.setupGuides?.forEach((g, i) => {
|
|
240
|
+
if (guideIds.has(g.id))
|
|
241
|
+
issue(["setupGuides", i, "id"], `duplicate setup guide id ${g.id}`);
|
|
242
|
+
guideIds.add(g.id);
|
|
243
|
+
if ((g.steps?.length ?? 0) === 0 && (g.files?.length ?? 0) === 0) {
|
|
244
|
+
issue(["setupGuides", i], "a setup guide needs at least one step or file");
|
|
245
|
+
}
|
|
246
|
+
const modes = SETUP_GUIDE_MODES[g.place];
|
|
247
|
+
const badMode = (mode, path) => {
|
|
248
|
+
if (mode !== undefined && !modes.includes(mode)) {
|
|
249
|
+
issue(path, `mode must be one of ${modes.join(", ")} for place ${g.place}`);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
g.steps?.forEach((s, j) => {
|
|
253
|
+
badMode(s.mode, ["setupGuides", i, "steps", j, "mode"]);
|
|
254
|
+
});
|
|
255
|
+
const paths = new Set();
|
|
256
|
+
g.files?.forEach((f, j) => {
|
|
257
|
+
badMode(f.mode, ["setupGuides", i, "files", j, "mode"]);
|
|
258
|
+
if (paths.has(f.path)) {
|
|
259
|
+
issue(["setupGuides", i, "files", j, "path"], `duplicate file ${f.path} in the guide`);
|
|
260
|
+
}
|
|
261
|
+
paths.add(f.path);
|
|
262
|
+
});
|
|
263
|
+
if (g.place === "channel-connection" &&
|
|
264
|
+
m.extension !== undefined &&
|
|
265
|
+
!m.extension.manifest.provides.some((p) => p.startsWith("channel@"))) {
|
|
266
|
+
issue(["setupGuides", i, "place"], "a channel-connection guide needs the plugin's extension to provide channel@1");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
209
269
|
if (m.extension === undefined &&
|
|
210
270
|
m.skills?.length === 0 &&
|
|
211
271
|
m.connectorTemplates?.length === 0 &&
|
|
@@ -281,6 +341,7 @@ const templateFile = z
|
|
|
281
341
|
message: "icon must be a relative .svg or .png path inside the plugin",
|
|
282
342
|
})
|
|
283
343
|
.optional(),
|
|
344
|
+
defaultEnabled: z.boolean().optional(),
|
|
284
345
|
template,
|
|
285
346
|
})
|
|
286
347
|
// Field rules (no credentials, auth presets, editableEndpoint, ...) belong to validateConnectorTemplate.
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -21,11 +21,23 @@ export interface SandboxLimits {
|
|
|
21
21
|
/** Egress allowlist (used when `egress` is `allowlist`); each entry passes {@link validateAllowHost}. */
|
|
22
22
|
allowHosts: string[];
|
|
23
23
|
}
|
|
24
|
+
/** The absolute roots inside one instance (a device may place them anywhere on its host). */
|
|
25
|
+
export interface SandboxRoots {
|
|
26
|
+
workspace: string;
|
|
27
|
+
skills: string;
|
|
28
|
+
tmp: string;
|
|
29
|
+
}
|
|
30
|
+
/** The roots every container-like provider uses; the same paths as {@link SANDBOX_ROOTS}. */
|
|
31
|
+
export declare const DEFAULT_SANDBOX_ROOTS: Readonly<SandboxRoots>;
|
|
24
32
|
/** The provider's instance handle. */
|
|
25
33
|
export interface SandboxHandle {
|
|
26
34
|
/** The provider's instance id. */
|
|
27
35
|
id: string;
|
|
36
|
+
/** The instance's roots; absent means {@link DEFAULT_SANDBOX_ROOTS}. */
|
|
37
|
+
roots?: SandboxRoots;
|
|
28
38
|
}
|
|
39
|
+
/** The roots of an instance, falling back to {@link DEFAULT_SANDBOX_ROOTS}. */
|
|
40
|
+
export declare function rootsOf(h: SandboxHandle): SandboxRoots;
|
|
29
41
|
/** The provider's file system handle (an environment's files, independent of any instance). */
|
|
30
42
|
export interface FileSystemHandle {
|
|
31
43
|
/** The provider's file system id. */
|
|
@@ -226,7 +238,17 @@ export interface SandboxProvider {
|
|
|
226
238
|
isDir: boolean;
|
|
227
239
|
} | null>;
|
|
228
240
|
}
|
|
229
|
-
/**
|
|
230
|
-
|
|
241
|
+
/**
|
|
242
|
+
* The root-independent checks of a path inside an instance (non-empty, at most 4096 characters, no
|
|
243
|
+
* control characters or backslashes, absolute, no empty or `..` segments); returns the errors,
|
|
244
|
+
* empty when valid. {@link validateSandboxPath} adds root membership.
|
|
245
|
+
*/
|
|
246
|
+
export declare function validateSandboxPathShape(p: string): string[];
|
|
247
|
+
/**
|
|
248
|
+
* Checks a path inside an instance; returns the errors, empty when valid: the
|
|
249
|
+
* {@link validateSandboxPathShape} checks, then root membership. `roots`: the instance's roots
|
|
250
|
+
* ({@link rootsOf}); absent means {@link SANDBOX_ROOTS}. The path must be one of them or under one.
|
|
251
|
+
*/
|
|
252
|
+
export declare function validateSandboxPath(p: string, roots?: SandboxRoots): string[];
|
|
231
253
|
/** 校验出站白名单主机名:小写、无协议与端口、可选前导 `*.`、IDN 只认 punycode。 */
|
|
232
254
|
export declare function validateAllowHost(h: string): string[];
|
package/dist/sandbox.js
CHANGED
|
@@ -13,6 +13,16 @@ export const SANDBOX_EXTENSION_POINT = "sandbox@2";
|
|
|
13
13
|
/** The roots inside the sandbox the model may access. */
|
|
14
14
|
export const SANDBOX_ROOTS = ["/workspace", "/skills", "/tmp"];
|
|
15
15
|
const MAX_PATH_LENGTH = 4096;
|
|
16
|
+
/** The roots every container-like provider uses; the same paths as {@link SANDBOX_ROOTS}. */
|
|
17
|
+
export const DEFAULT_SANDBOX_ROOTS = Object.freeze({
|
|
18
|
+
workspace: "/workspace",
|
|
19
|
+
skills: "/skills",
|
|
20
|
+
tmp: "/tmp",
|
|
21
|
+
});
|
|
22
|
+
/** The roots of an instance, falling back to {@link DEFAULT_SANDBOX_ROOTS}. */
|
|
23
|
+
export function rootsOf(h) {
|
|
24
|
+
return h.roots ?? DEFAULT_SANDBOX_ROOTS;
|
|
25
|
+
}
|
|
16
26
|
/**
|
|
17
27
|
* The instance is definitely gone on the provider's side (container removed, E2B sandbox expired).
|
|
18
28
|
* `exec` / `readFile` / `writeFile` / `stat` must throw it (not a plain Error) when they find the
|
|
@@ -58,8 +68,12 @@ export function isFileSystemGoneError(e) {
|
|
|
58
68
|
export const SANDBOX_TAG_RE = /^[a-z0-9-]{1,64}$/;
|
|
59
69
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: 正是要拦控制字符
|
|
60
70
|
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
61
|
-
/**
|
|
62
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The root-independent checks of a path inside an instance (non-empty, at most 4096 characters, no
|
|
73
|
+
* control characters or backslashes, absolute, no empty or `..` segments); returns the errors,
|
|
74
|
+
* empty when valid. {@link validateSandboxPath} adds root membership.
|
|
75
|
+
*/
|
|
76
|
+
export function validateSandboxPathShape(p) {
|
|
63
77
|
if (typeof p !== "string" || p.length === 0)
|
|
64
78
|
return ["path must be a non-empty string"];
|
|
65
79
|
if (p.length > MAX_PATH_LENGTH)
|
|
@@ -72,22 +86,41 @@ export function validateSandboxPath(p) {
|
|
|
72
86
|
return ["path must be absolute"];
|
|
73
87
|
if (p.includes("//"))
|
|
74
88
|
return ["path must not contain empty segments"];
|
|
75
|
-
const errors = [];
|
|
76
89
|
for (const seg of p.split("/")) {
|
|
77
90
|
let decoded = seg;
|
|
78
91
|
try {
|
|
79
92
|
decoded = decodeURIComponent(seg);
|
|
80
93
|
}
|
|
81
94
|
catch {
|
|
82
|
-
//
|
|
95
|
+
// invalid percent-encoding is taken literally
|
|
83
96
|
}
|
|
84
97
|
if (seg === ".." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
|
|
85
|
-
|
|
86
|
-
break;
|
|
98
|
+
return ["path must not contain '..' segments"];
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
/** A structural error after which {@link validateSandboxPath} stops (before the root check). */
|
|
104
|
+
const STOPS = new Set([
|
|
105
|
+
"path must be a non-empty string",
|
|
106
|
+
`path must be at most ${MAX_PATH_LENGTH} characters`,
|
|
107
|
+
"path must not contain control characters",
|
|
108
|
+
"path must not contain backslashes",
|
|
109
|
+
"path must be absolute",
|
|
110
|
+
"path must not contain empty segments",
|
|
111
|
+
]);
|
|
112
|
+
/**
|
|
113
|
+
* Checks a path inside an instance; returns the errors, empty when valid: the
|
|
114
|
+
* {@link validateSandboxPathShape} checks, then root membership. `roots`: the instance's roots
|
|
115
|
+
* ({@link rootsOf}); absent means {@link SANDBOX_ROOTS}. The path must be one of them or under one.
|
|
116
|
+
*/
|
|
117
|
+
export function validateSandboxPath(p, roots) {
|
|
118
|
+
const errors = validateSandboxPathShape(p);
|
|
119
|
+
if (errors.some((e) => STOPS.has(e)))
|
|
120
|
+
return errors;
|
|
121
|
+
const allowed = roots === undefined ? SANDBOX_ROOTS : [roots.workspace, roots.skills, roots.tmp];
|
|
122
|
+
if (!allowed.some((r) => p === r || p.startsWith(`${r}/`))) {
|
|
123
|
+
errors.push(`path must be under ${allowed.join(", ")}`);
|
|
91
124
|
}
|
|
92
125
|
return errors;
|
|
93
126
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): setup material a plugin
|
|
3
|
+
* ships (steps and template files such as Slack's app manifest) that the kernel renders with a
|
|
4
|
+
* fixed set of safe, deterministic variables and the web shows where the person needs it. The
|
|
5
|
+
* format lives in `plugin.json` `setupGuides` ({@link PluginSetupGuide}); this module holds the
|
|
6
|
+
* places, their modes and variables, the limits and the pure renderer.
|
|
7
|
+
*
|
|
8
|
+
* Placeholders are `{{name}}` (spaces inside the braces allowed) with `name` matching
|
|
9
|
+
* `[A-Za-z][A-Za-z0-9]*`. There is no other syntax: no conditionals, loops, filters or escapes. A
|
|
10
|
+
* placeholder the place does not define, or one without a value, stays as written.
|
|
11
|
+
*/
|
|
12
|
+
/** Where a guide is shown. `channel-connection`: 新建渠道 and the channel detail page. */
|
|
13
|
+
export declare const SETUP_GUIDE_PLACES: readonly ["channel-connection"];
|
|
14
|
+
export type SetupGuidePlace = (typeof SETUP_GUIDE_PLACES)[number];
|
|
15
|
+
/**
|
|
16
|
+
* The modes of each place: a step or file with `mode` applies to that mode only.
|
|
17
|
+
* `channel-connection`: the connection's `inboundMode`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SETUP_GUIDE_MODES: Readonly<Record<SetupGuidePlace, readonly string[]>>;
|
|
20
|
+
/**
|
|
21
|
+
* The variables of each place. None is a secret: the kernel fills them from public URLs, names and
|
|
22
|
+
* versions only (`publicUrl`, `webhookUrl`, `signInCallbackUrl`, `connectionName`, `orgName`,
|
|
23
|
+
* `pluginName`, `pluginVersion`, `mode`).
|
|
24
|
+
*/
|
|
25
|
+
export declare const SETUP_GUIDE_VARIABLES: Readonly<Record<SetupGuidePlace, readonly string[]>>;
|
|
26
|
+
/** Languages of a template file: the code block's label and how values are escaped. */
|
|
27
|
+
export declare const SETUP_GUIDE_LANGUAGES: readonly ["yaml", "json", "text"];
|
|
28
|
+
export type SetupGuideLanguage = (typeof SETUP_GUIDE_LANGUAGES)[number];
|
|
29
|
+
/** Limits of setup guides. */
|
|
30
|
+
export declare const SETUP_GUIDE_LIMITS: {
|
|
31
|
+
/** Guides per plugin. */
|
|
32
|
+
readonly guides: 8;
|
|
33
|
+
/** Steps per guide. */
|
|
34
|
+
readonly steps: 20;
|
|
35
|
+
/** Files per guide. */
|
|
36
|
+
readonly files: 8;
|
|
37
|
+
/** Characters of a step (each language). */
|
|
38
|
+
readonly stepChars: 2000;
|
|
39
|
+
/** Characters of a title (each language). */
|
|
40
|
+
readonly titleChars: 100;
|
|
41
|
+
/** Bytes of a template file. */
|
|
42
|
+
readonly fileBytes: number;
|
|
43
|
+
};
|
|
44
|
+
/** File names a template file may have (checked on the path). */
|
|
45
|
+
export declare const SETUP_GUIDE_FILE_RE: RegExp;
|
|
46
|
+
/** Localized text: English, optional Chinese (falls back to English). */
|
|
47
|
+
export interface SetupGuideText {
|
|
48
|
+
en: string;
|
|
49
|
+
zh?: string;
|
|
50
|
+
}
|
|
51
|
+
/** A step: plain text (a backtick pair is shown as inline code), optionally for one mode. */
|
|
52
|
+
export interface SetupGuideStep extends SetupGuideText {
|
|
53
|
+
mode?: string;
|
|
54
|
+
}
|
|
55
|
+
/** A template file of a guide. */
|
|
56
|
+
export interface SetupGuideFile {
|
|
57
|
+
/** `./…` inside the plugin, `.tmpl` / `.yaml` / `.yml` / `.json` / `.txt`. */
|
|
58
|
+
path: string;
|
|
59
|
+
title: SetupGuideText;
|
|
60
|
+
language: SetupGuideLanguage;
|
|
61
|
+
mode?: string;
|
|
62
|
+
}
|
|
63
|
+
/** A setup guide in `plugin.json` `setupGuides`. */
|
|
64
|
+
export interface PluginSetupGuide {
|
|
65
|
+
/** `^[a-z0-9][a-z0-9-]{0,63}$`, unique in the plugin. */
|
|
66
|
+
id: string;
|
|
67
|
+
place: SetupGuidePlace;
|
|
68
|
+
title: SetupGuideText;
|
|
69
|
+
steps?: SetupGuideStep[];
|
|
70
|
+
files?: SetupGuideFile[];
|
|
71
|
+
}
|
|
72
|
+
/** The placeholder names a text uses, each once, in order of first use. */
|
|
73
|
+
export declare function setupGuidePlaceholders(text: string): string[];
|
|
74
|
+
/** The placeholders of a text that the place does not define (the toolchain refuses them). */
|
|
75
|
+
export declare function unknownSetupGuidePlaceholders(text: string, place: SetupGuidePlace): string[];
|
|
76
|
+
/** The modes a guide's steps and files use, in the place's order (empty: no mode switch). */
|
|
77
|
+
export declare function setupGuideModes(guide: PluginSetupGuide): string[];
|
|
78
|
+
/**
|
|
79
|
+
* Renders a guide text: each `{{name}}` with a value in `values` is replaced by it, escaped for
|
|
80
|
+
* `language` (`yaml` / `json`: JSON-string-escaped without quotes, so a placeholder inside double
|
|
81
|
+
* quotes stays one string; `text`: control characters become spaces). A placeholder whose value is
|
|
82
|
+
* null or undefined stays as written and is listed in `unresolved` when `known` holds it; one not
|
|
83
|
+
* in `known` stays as written and is not listed. Never runs code; deterministic.
|
|
84
|
+
*/
|
|
85
|
+
export declare function renderSetupGuideText(text: string, values: Readonly<Record<string, string | null | undefined>>, language: SetupGuideLanguage, known?: readonly string[]): {
|
|
86
|
+
text: string;
|
|
87
|
+
unresolved: string[];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Whether a guide's template file is usable: present, at most {@link SETUP_GUIDE_LIMITS}`.fileBytes`,
|
|
91
|
+
* valid UTF-8 text without NUL. The problem (English, naming the path), or undefined when it is.
|
|
92
|
+
* Shared by the packer and the kernel's package reader.
|
|
93
|
+
*/
|
|
94
|
+
export declare function setupGuideFileProblem(path: string, data: Uint8Array | undefined): string | undefined;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): setup material a plugin
|
|
3
|
+
* ships (steps and template files such as Slack's app manifest) that the kernel renders with a
|
|
4
|
+
* fixed set of safe, deterministic variables and the web shows where the person needs it. The
|
|
5
|
+
* format lives in `plugin.json` `setupGuides` ({@link PluginSetupGuide}); this module holds the
|
|
6
|
+
* places, their modes and variables, the limits and the pure renderer.
|
|
7
|
+
*
|
|
8
|
+
* Placeholders are `{{name}}` (spaces inside the braces allowed) with `name` matching
|
|
9
|
+
* `[A-Za-z][A-Za-z0-9]*`. There is no other syntax: no conditionals, loops, filters or escapes. A
|
|
10
|
+
* placeholder the place does not define, or one without a value, stays as written.
|
|
11
|
+
*/
|
|
12
|
+
/** Where a guide is shown. `channel-connection`: 新建渠道 and the channel detail page. */
|
|
13
|
+
export const SETUP_GUIDE_PLACES = ["channel-connection"];
|
|
14
|
+
/**
|
|
15
|
+
* The modes of each place: a step or file with `mode` applies to that mode only.
|
|
16
|
+
* `channel-connection`: the connection's `inboundMode`.
|
|
17
|
+
*/
|
|
18
|
+
export const SETUP_GUIDE_MODES = {
|
|
19
|
+
"channel-connection": ["long_connection", "webhook"],
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The variables of each place. None is a secret: the kernel fills them from public URLs, names and
|
|
23
|
+
* versions only (`publicUrl`, `webhookUrl`, `signInCallbackUrl`, `connectionName`, `orgName`,
|
|
24
|
+
* `pluginName`, `pluginVersion`, `mode`).
|
|
25
|
+
*/
|
|
26
|
+
export const SETUP_GUIDE_VARIABLES = {
|
|
27
|
+
"channel-connection": [
|
|
28
|
+
"publicUrl",
|
|
29
|
+
"webhookUrl",
|
|
30
|
+
"signInCallbackUrl",
|
|
31
|
+
"connectionName",
|
|
32
|
+
"orgName",
|
|
33
|
+
"pluginName",
|
|
34
|
+
"pluginVersion",
|
|
35
|
+
"mode",
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
/** Languages of a template file: the code block's label and how values are escaped. */
|
|
39
|
+
export const SETUP_GUIDE_LANGUAGES = ["yaml", "json", "text"];
|
|
40
|
+
/** Limits of setup guides. */
|
|
41
|
+
export const SETUP_GUIDE_LIMITS = {
|
|
42
|
+
/** Guides per plugin. */
|
|
43
|
+
guides: 8,
|
|
44
|
+
/** Steps per guide. */
|
|
45
|
+
steps: 20,
|
|
46
|
+
/** Files per guide. */
|
|
47
|
+
files: 8,
|
|
48
|
+
/** Characters of a step (each language). */
|
|
49
|
+
stepChars: 2000,
|
|
50
|
+
/** Characters of a title (each language). */
|
|
51
|
+
titleChars: 100,
|
|
52
|
+
/** Bytes of a template file. */
|
|
53
|
+
fileBytes: 64 * 1024,
|
|
54
|
+
};
|
|
55
|
+
/** File names a template file may have (checked on the path). */
|
|
56
|
+
export const SETUP_GUIDE_FILE_RE = /\.(?:tmpl|ya?ml|json|txt)$/i;
|
|
57
|
+
const PLACEHOLDER_RE = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
|
|
58
|
+
/** The placeholder names a text uses, each once, in order of first use. */
|
|
59
|
+
export function setupGuidePlaceholders(text) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const m of text.matchAll(PLACEHOLDER_RE)) {
|
|
62
|
+
const name = m[1];
|
|
63
|
+
if (!out.includes(name))
|
|
64
|
+
out.push(name);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** The placeholders of a text that the place does not define (the toolchain refuses them). */
|
|
69
|
+
export function unknownSetupGuidePlaceholders(text, place) {
|
|
70
|
+
const known = SETUP_GUIDE_VARIABLES[place];
|
|
71
|
+
return setupGuidePlaceholders(text).filter((n) => !known.includes(n));
|
|
72
|
+
}
|
|
73
|
+
/** The modes a guide's steps and files use, in the place's order (empty: no mode switch). */
|
|
74
|
+
export function setupGuideModes(guide) {
|
|
75
|
+
const used = new Set();
|
|
76
|
+
for (const s of guide.steps ?? [])
|
|
77
|
+
if (s.mode !== undefined)
|
|
78
|
+
used.add(s.mode);
|
|
79
|
+
for (const f of guide.files ?? [])
|
|
80
|
+
if (f.mode !== undefined)
|
|
81
|
+
used.add(f.mode);
|
|
82
|
+
return SETUP_GUIDE_MODES[guide.place].filter((m) => used.has(m));
|
|
83
|
+
}
|
|
84
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is replaced
|
|
85
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/g;
|
|
86
|
+
/** A value as inserted into a text of the language (see {@link renderSetupGuideText}). */
|
|
87
|
+
function escapeValue(value, language) {
|
|
88
|
+
if (language === "text")
|
|
89
|
+
return value.replace(CONTROL_RE, " ");
|
|
90
|
+
// JSON string escaping without the quotes: valid inside a JSON string and a YAML double-quoted
|
|
91
|
+
// scalar, whatever the value holds
|
|
92
|
+
return JSON.stringify(value).slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Renders a guide text: each `{{name}}` with a value in `values` is replaced by it, escaped for
|
|
96
|
+
* `language` (`yaml` / `json`: JSON-string-escaped without quotes, so a placeholder inside double
|
|
97
|
+
* quotes stays one string; `text`: control characters become spaces). A placeholder whose value is
|
|
98
|
+
* null or undefined stays as written and is listed in `unresolved` when `known` holds it; one not
|
|
99
|
+
* in `known` stays as written and is not listed. Never runs code; deterministic.
|
|
100
|
+
*/
|
|
101
|
+
export function renderSetupGuideText(text, values, language, known = Object.keys(values)) {
|
|
102
|
+
const unresolved = [];
|
|
103
|
+
const out = text.replace(PLACEHOLDER_RE, (whole, name) => {
|
|
104
|
+
if (!known.includes(name))
|
|
105
|
+
return whole;
|
|
106
|
+
const v = Object.hasOwn(values, name) ? values[name] : undefined;
|
|
107
|
+
if (v === null || v === undefined) {
|
|
108
|
+
if (!unresolved.includes(name))
|
|
109
|
+
unresolved.push(name);
|
|
110
|
+
return whole;
|
|
111
|
+
}
|
|
112
|
+
return escapeValue(v, language);
|
|
113
|
+
});
|
|
114
|
+
return { text: out, unresolved };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Whether a guide's template file is usable: present, at most {@link SETUP_GUIDE_LIMITS}`.fileBytes`,
|
|
118
|
+
* valid UTF-8 text without NUL. The problem (English, naming the path), or undefined when it is.
|
|
119
|
+
* Shared by the packer and the kernel's package reader.
|
|
120
|
+
*/
|
|
121
|
+
export function setupGuideFileProblem(path, data) {
|
|
122
|
+
if (data === undefined)
|
|
123
|
+
return `${path} not found in the package`;
|
|
124
|
+
if (data.byteLength > SETUP_GUIDE_LIMITS.fileBytes) {
|
|
125
|
+
return `${path} is larger than ${SETUP_GUIDE_LIMITS.fileBytes} bytes`;
|
|
126
|
+
}
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return `${path} is not valid UTF-8 text`;
|
|
133
|
+
}
|
|
134
|
+
return text.includes("\u0000") ? `${path} is not a text file (it contains NUL)` : undefined;
|
|
135
|
+
}
|
|
@@ -27,13 +27,14 @@ export declare function parseSkillArguments(args: string): string[];
|
|
|
27
27
|
/**
|
|
28
28
|
* A skill body with its arguments substituted (see the file header), without the wrapper.
|
|
29
29
|
* `skillName` is the resolved skill's bare name and `digest` its version's `skillDigest`:
|
|
30
|
-
* `${CLAUDE_SKILL_DIR}` becomes
|
|
31
|
-
*
|
|
30
|
+
* `${CLAUDE_SKILL_DIR}` becomes `<skillsRoot>/<skillName>@<digest>` (`skillsRoot`: the instance's
|
|
31
|
+
* skills root, default `/skills`; a qualified `<plugin>:<name>` or a digest that is not 12
|
|
32
|
+
* lowercase hex digits throws a `TypeError`). `args` is trimmed;
|
|
32
33
|
* more than {@link SKILL_INVOCATION_LIMITS.argumentsMaxChars} characters throw a `RangeError`, and
|
|
33
34
|
* so does a result longer than {@link SKILL_INVOCATION_LIMITS.renderedMaxChars} (checked while
|
|
34
35
|
* substituting, before the long string is built).
|
|
35
36
|
*/
|
|
36
|
-
export declare function renderSkillBody(body: string, invocation: SkillInvocation, args: string, skillName: string, digest: string): string;
|
|
37
|
+
export declare function renderSkillBody(body: string, invocation: SkillInvocation, args: string, skillName: string, digest: string, skillsRoot?: string): string;
|
|
37
38
|
/** Input of {@link renderSkillInvocation}. */
|
|
38
39
|
export interface RenderSkillInvocationInput {
|
|
39
40
|
/**
|
package/dist/skill-invocation.js
CHANGED
|
@@ -112,16 +112,17 @@ const tooLong = () => new RangeError(`the rendered skill must be at most ${SKILL
|
|
|
112
112
|
/**
|
|
113
113
|
* A skill body with its arguments substituted (see the file header), without the wrapper.
|
|
114
114
|
* `skillName` is the resolved skill's bare name and `digest` its version's `skillDigest`:
|
|
115
|
-
* `${CLAUDE_SKILL_DIR}` becomes
|
|
116
|
-
*
|
|
115
|
+
* `${CLAUDE_SKILL_DIR}` becomes `<skillsRoot>/<skillName>@<digest>` (`skillsRoot`: the instance's
|
|
116
|
+
* skills root, default `/skills`; a qualified `<plugin>:<name>` or a digest that is not 12
|
|
117
|
+
* lowercase hex digits throws a `TypeError`). `args` is trimmed;
|
|
117
118
|
* more than {@link SKILL_INVOCATION_LIMITS.argumentsMaxChars} characters throw a `RangeError`, and
|
|
118
119
|
* so does a result longer than {@link SKILL_INVOCATION_LIMITS.renderedMaxChars} (checked while
|
|
119
120
|
* substituting, before the long string is built).
|
|
120
121
|
*/
|
|
121
|
-
export function renderSkillBody(body, invocation, args, skillName, digest) {
|
|
122
|
+
export function renderSkillBody(body, invocation, args, skillName, digest, skillsRoot = SKILLS_SANDBOX_ROOT) {
|
|
122
123
|
checkSkillName(skillName);
|
|
123
124
|
checkDigest(digest);
|
|
124
|
-
const dir = skillSandboxDir(skillName, digest);
|
|
125
|
+
const dir = skillSandboxDir(skillName, digest, skillsRoot);
|
|
125
126
|
const all = trimmedArgs(args);
|
|
126
127
|
const values = parseSkillArguments(all);
|
|
127
128
|
const named = invocation.arguments ?? [];
|
package/dist/skill.d.ts
CHANGED
|
@@ -49,10 +49,13 @@ export declare function skillDigest(files: {
|
|
|
49
49
|
size: number;
|
|
50
50
|
sha256: string;
|
|
51
51
|
}[]): string;
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
/**
|
|
53
|
+
* A skill version's directory in the sandbox: `<root>/<name>@<digest>` ({@link skillDigest});
|
|
54
|
+
* `root` is the instance's skills root (`SandboxRoots.skills`, default `/skills`).
|
|
55
|
+
*/
|
|
56
|
+
export declare function skillSandboxDir(name: string, digest: string, root?: string): string;
|
|
57
|
+
/** A skill file's path in the sandbox: `<root>/<name>@<digest>/<path>` ({@link skillSandboxDir}). */
|
|
58
|
+
export declare function skillSandboxPath(name: string, digest: string, path: string, root?: string): string;
|
|
56
59
|
/** {@link parseSkillMd} 成功时的结果。 */
|
|
57
60
|
export interface ParsedSkillMd {
|
|
58
61
|
name: string;
|
package/dist/skill.js
CHANGED
|
@@ -81,13 +81,16 @@ export function skillDigest(files) {
|
|
|
81
81
|
.join("");
|
|
82
82
|
return createHash("sha256").update(lines).digest("hex").slice(0, 12);
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
/**
|
|
85
|
+
* A skill version's directory in the sandbox: `<root>/<name>@<digest>` ({@link skillDigest});
|
|
86
|
+
* `root` is the instance's skills root (`SandboxRoots.skills`, default `/skills`).
|
|
87
|
+
*/
|
|
88
|
+
export function skillSandboxDir(name, digest, root = SKILLS_SANDBOX_ROOT) {
|
|
89
|
+
return `${root}/${name}@${digest}`;
|
|
87
90
|
}
|
|
88
|
-
/** A skill file's path in the sandbox:
|
|
89
|
-
export function skillSandboxPath(name, digest, path) {
|
|
90
|
-
return `${skillSandboxDir(name, digest)}/${path}`;
|
|
91
|
+
/** A skill file's path in the sandbox: `<root>/<name>@<digest>/<path>` ({@link skillSandboxDir}). */
|
|
92
|
+
export function skillSandboxPath(name, digest, path, root = SKILLS_SANDBOX_ROOT) {
|
|
93
|
+
return `${skillSandboxDir(name, digest, root)}/${path}`;
|
|
91
94
|
}
|
|
92
95
|
const chars = (s) => [...s].length;
|
|
93
96
|
/**
|