@capybara-hq/plugin-sdk 0.1.0-rc.5 → 0.1.0-rc.6

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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Device protocol v1: the WebSocket frames between the kernel and a `capybara-device` program
3
+ * (a connected machine that serves as an environment). Pure types and hand-written guards.
4
+ */
5
+ import { type ExecResult, type SandboxActor, type SandboxLimits, type SandboxRoots } from "./sandbox.js";
6
+ /** The protocol version carried in `X-Capybara-Device-Protocol` and the hello frame. */
7
+ export declare const DEVICE_PROTOCOL_VERSION = 1;
8
+ /** The operations the kernel asks a device to run. */
9
+ export type DeviceOp = "create" | "exec" | "readFile" | "writeFile" | "stat" | "killTagged" | "killAll" | "stop";
10
+ /** First frame from the device after the WebSocket opens. */
11
+ export interface DeviceHello {
12
+ type: "hello";
13
+ protocol: number;
14
+ programVersion: string;
15
+ os: "darwin" | "linux";
16
+ arch: string;
17
+ mode: "os" | "docker";
18
+ roots: SandboxRoots;
19
+ sessionIsolation: boolean;
20
+ }
21
+ /** Error kinds a device reports for a failed request. */
22
+ export type DeviceErrorKind = "gone" | "fs_gone" | "policy" | "error";
23
+ /** The device's answer to a {@link KernelReq}. */
24
+ export type DeviceRes = {
25
+ type: "res";
26
+ id: string;
27
+ ok: true;
28
+ result: unknown;
29
+ } | {
30
+ type: "res";
31
+ id: string;
32
+ ok: false;
33
+ error: {
34
+ kind: DeviceErrorKind;
35
+ message: string;
36
+ };
37
+ };
38
+ /** A request from the kernel. */
39
+ export interface KernelReq {
40
+ type: "req";
41
+ id: string;
42
+ op: DeviceOp;
43
+ args: unknown;
44
+ deadlineMs: number;
45
+ }
46
+ /** The kernel gives up on a request (abort or deadline). */
47
+ export interface KernelCancel {
48
+ type: "cancel";
49
+ id: string;
50
+ }
51
+ export type DeviceFrame = DeviceHello | DeviceRes | KernelReq | KernelCancel;
52
+ export interface DeviceExecArgs {
53
+ command: string;
54
+ cwd?: string;
55
+ env?: Record<string, string>;
56
+ timeoutMs: number;
57
+ maxOutputBytes: number;
58
+ tag: string;
59
+ actor: SandboxActor;
60
+ limits: SandboxLimits;
61
+ }
62
+ export type DeviceExecResult = ExecResult;
63
+ export interface DeviceReadFileArgs {
64
+ path: string;
65
+ maxBytes: number;
66
+ }
67
+ export type DeviceReadFileResult = {
68
+ dataB64: string;
69
+ truncated: boolean;
70
+ } | null;
71
+ export interface DeviceWriteFileArgs {
72
+ path: string;
73
+ dataB64: string;
74
+ }
75
+ export type DeviceWriteFileResult = null;
76
+ export interface DeviceStatArgs {
77
+ path: string;
78
+ }
79
+ export type DeviceStatResult = {
80
+ size: number;
81
+ isDir: boolean;
82
+ } | null;
83
+ export interface DeviceKillTaggedArgs {
84
+ tags: string[];
85
+ }
86
+ export type DeviceKillAllArgs = Record<string, never>;
87
+ export interface DeviceCreateArgs {
88
+ limits: SandboxLimits;
89
+ }
90
+ export interface DeviceCreateResult {
91
+ roots: SandboxRoots;
92
+ }
93
+ export interface DeviceStopArgs {
94
+ reason: "revoked" | "deleted";
95
+ }
96
+ /** A frame that is not valid protocol v1 (or came from the wrong side). */
97
+ export declare class DeviceProtocolError extends Error {
98
+ constructor(message: string);
99
+ }
100
+ /** Result type per op. */
101
+ export interface DeviceOpResults {
102
+ create: DeviceCreateResult;
103
+ exec: DeviceExecResult;
104
+ readFile: DeviceReadFileResult;
105
+ writeFile: DeviceWriteFileResult;
106
+ stat: DeviceStatResult;
107
+ killTagged: null;
108
+ killAll: null;
109
+ stop: null;
110
+ }
111
+ /** Validates the `result` of a successful `res` for `op`; throws {@link DeviceProtocolError}. */
112
+ export declare function parseOpResult<Op extends DeviceOp>(op: Op, result: unknown): DeviceOpResults[Op];
113
+ /**
114
+ * Parses one WebSocket text frame. `from` is the sender: a device may send `hello` and `res`, the
115
+ * kernel `req` and `cancel`. Throws {@link DeviceProtocolError} on bad JSON, an unknown `type`,
116
+ * missing fields or a frame of the wrong direction.
117
+ */
118
+ export declare function parseDeviceFrame(raw: string, from: "device" | "kernel"): DeviceFrame;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Device protocol v1: the WebSocket frames between the kernel and a `capybara-device` program
3
+ * (a connected machine that serves as an environment). Pure types and hand-written guards.
4
+ */
5
+ import { validateSandboxPathShape, } from "./sandbox.js";
6
+ /** The protocol version carried in `X-Capybara-Device-Protocol` and the hello frame. */
7
+ export const DEVICE_PROTOCOL_VERSION = 1;
8
+ const DEVICE_OPS = [
9
+ "create",
10
+ "exec",
11
+ "readFile",
12
+ "writeFile",
13
+ "stat",
14
+ "killTagged",
15
+ "killAll",
16
+ "stop",
17
+ ];
18
+ /** A frame that is not valid protocol v1 (or came from the wrong side). */
19
+ export class DeviceProtocolError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "DeviceProtocolError";
23
+ }
24
+ }
25
+ function isRecord(v) {
26
+ return typeof v === "object" && v !== null && !Array.isArray(v);
27
+ }
28
+ function str(o, k) {
29
+ const v = o[k];
30
+ if (typeof v !== "string")
31
+ throw new DeviceProtocolError(`field "${k}" must be a string`);
32
+ return v;
33
+ }
34
+ function nonEmptyStr(o, k) {
35
+ const v = str(o, k);
36
+ if (v === "")
37
+ throw new DeviceProtocolError(`field "${k}" must not be empty`);
38
+ return v;
39
+ }
40
+ function posInt(o, k) {
41
+ const v = o[k];
42
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
43
+ throw new DeviceProtocolError(`field "${k}" must be a positive integer`);
44
+ }
45
+ return v;
46
+ }
47
+ function obj(v, what) {
48
+ if (!isRecord(v))
49
+ throw new DeviceProtocolError(`${what} must be an object`);
50
+ return v;
51
+ }
52
+ function bool(o, k) {
53
+ const v = o[k];
54
+ if (typeof v !== "boolean")
55
+ throw new DeviceProtocolError(`field "${k}" must be a boolean`);
56
+ return v;
57
+ }
58
+ function strArray(o, k) {
59
+ const v = o[k];
60
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
61
+ throw new DeviceProtocolError(`field "${k}" must be an array of strings`);
62
+ }
63
+ return v;
64
+ }
65
+ function parseLimits(v) {
66
+ const o = obj(v, 'field "limits"');
67
+ const egress = str(o, "egress");
68
+ if (egress !== "none" && egress !== "allowlist" && egress !== "open") {
69
+ throw new DeviceProtocolError(`unknown egress "${egress}"`);
70
+ }
71
+ return {
72
+ cpu: num(o, "cpu"),
73
+ memoryMb: num(o, "memoryMb"),
74
+ diskMb: num(o, "diskMb"),
75
+ egress,
76
+ allowHosts: strArray(o, "allowHosts"),
77
+ };
78
+ }
79
+ function num(o, k) {
80
+ const v = o[k];
81
+ if (typeof v !== "number" || !Number.isFinite(v)) {
82
+ throw new DeviceProtocolError(`field "${k}" must be a number`);
83
+ }
84
+ return v;
85
+ }
86
+ function parseRoots(v) {
87
+ if (!isRecord(v))
88
+ throw new DeviceProtocolError('field "roots" must be an object');
89
+ const roots = {};
90
+ for (const k of ["workspace", "skills", "tmp"]) {
91
+ const p = str(v, k);
92
+ if (!p.startsWith("/")) {
93
+ throw new DeviceProtocolError(`root "${k}" must be an absolute path`);
94
+ }
95
+ // the kernel builds paths as `${root}/…` and checks membership by prefix: a root must be a
96
+ // clean absolute directory path
97
+ const shape = validateSandboxPathShape(p);
98
+ if (shape.length > 0)
99
+ throw new DeviceProtocolError(`root "${k}": ${shape.join("; ")}`);
100
+ if (p.endsWith("/")) {
101
+ throw new DeviceProtocolError(`root "${k}" must not end with a slash`);
102
+ }
103
+ roots[k] = p;
104
+ }
105
+ return roots;
106
+ }
107
+ function parseHello(o) {
108
+ const os = str(o, "os");
109
+ if (os !== "darwin" && os !== "linux")
110
+ throw new DeviceProtocolError(`unsupported os "${os}"`);
111
+ const mode = str(o, "mode");
112
+ if (mode !== "os" && mode !== "docker") {
113
+ throw new DeviceProtocolError(`unsupported mode "${mode}"`);
114
+ }
115
+ if (typeof o.sessionIsolation !== "boolean") {
116
+ throw new DeviceProtocolError('field "sessionIsolation" must be a boolean');
117
+ }
118
+ return {
119
+ type: "hello",
120
+ protocol: posInt(o, "protocol"),
121
+ programVersion: str(o, "programVersion"),
122
+ os,
123
+ arch: str(o, "arch"),
124
+ mode,
125
+ roots: parseRoots(o.roots),
126
+ sessionIsolation: o.sessionIsolation,
127
+ };
128
+ }
129
+ function parseRes(o) {
130
+ const id = nonEmptyStr(o, "id");
131
+ if (o.ok === true)
132
+ return { type: "res", id, ok: true, result: o.result };
133
+ if (o.ok !== false)
134
+ throw new DeviceProtocolError('field "ok" must be a boolean');
135
+ const e = o.error;
136
+ if (!isRecord(e))
137
+ throw new DeviceProtocolError('field "error" must be an object');
138
+ const kind = str(e, "kind");
139
+ if (kind !== "gone" && kind !== "fs_gone" && kind !== "policy" && kind !== "error") {
140
+ throw new DeviceProtocolError(`unknown error kind "${kind}"`);
141
+ }
142
+ return { type: "res", id, ok: false, error: { kind, message: str(e, "message") } };
143
+ }
144
+ function parseReq(o) {
145
+ const op = str(o, "op");
146
+ if (!DEVICE_OPS.includes(op))
147
+ throw new DeviceProtocolError(`unknown op "${op}"`);
148
+ const args = obj(o.args, 'field "args"');
149
+ validateArgs(op, args);
150
+ return {
151
+ type: "req",
152
+ id: nonEmptyStr(o, "id"),
153
+ op: op,
154
+ args,
155
+ deadlineMs: posInt(o, "deadlineMs"),
156
+ };
157
+ }
158
+ function validateArgs(op, a) {
159
+ switch (op) {
160
+ case "exec": {
161
+ nonEmptyStr(a, "command");
162
+ posInt(a, "timeoutMs");
163
+ posInt(a, "maxOutputBytes");
164
+ str(a, "tag");
165
+ const actor = obj(a.actor, 'field "actor"');
166
+ str(actor, "accountId");
167
+ str(actor, "conversationId");
168
+ parseLimits(a.limits);
169
+ if (a.cwd !== undefined)
170
+ str(a, "cwd");
171
+ if (a.env !== undefined) {
172
+ const env = obj(a.env, 'field "env"');
173
+ for (const k of Object.keys(env))
174
+ str(env, k);
175
+ }
176
+ return;
177
+ }
178
+ case "readFile": {
179
+ str(a, "path");
180
+ const m = a.maxBytes;
181
+ if (typeof m !== "number" || !Number.isInteger(m) || m < 0) {
182
+ throw new DeviceProtocolError('field "maxBytes" must be a non-negative integer');
183
+ }
184
+ return;
185
+ }
186
+ case "writeFile":
187
+ str(a, "path");
188
+ str(a, "dataB64");
189
+ return;
190
+ case "stat":
191
+ str(a, "path");
192
+ return;
193
+ case "killTagged":
194
+ strArray(a, "tags");
195
+ return;
196
+ case "killAll":
197
+ return;
198
+ case "create":
199
+ parseLimits(a.limits);
200
+ return;
201
+ case "stop": {
202
+ const r = str(a, "reason");
203
+ if (r !== "revoked" && r !== "deleted") {
204
+ throw new DeviceProtocolError(`unknown stop reason "${r}"`);
205
+ }
206
+ return;
207
+ }
208
+ }
209
+ }
210
+ export function parseOpResult(op, result) {
211
+ switch (op) {
212
+ case "exec": {
213
+ const r = obj(result, "exec result");
214
+ const exitCode = r.exitCode;
215
+ if (exitCode !== null && (typeof exitCode !== "number" || !Number.isFinite(exitCode))) {
216
+ throw new DeviceProtocolError('field "exitCode" must be a number or null');
217
+ }
218
+ return {
219
+ exitCode: exitCode,
220
+ stdout: str(r, "stdout"),
221
+ stderr: str(r, "stderr"),
222
+ truncated: bool(r, "truncated"),
223
+ timedOut: bool(r, "timedOut"),
224
+ };
225
+ }
226
+ case "readFile": {
227
+ if (result === null)
228
+ return null;
229
+ const r = obj(result, "readFile result");
230
+ return { dataB64: str(r, "dataB64"), truncated: bool(r, "truncated") };
231
+ }
232
+ case "stat": {
233
+ if (result === null)
234
+ return null;
235
+ const r = obj(result, "stat result");
236
+ return { size: num(r, "size"), isDir: bool(r, "isDir") };
237
+ }
238
+ case "create":
239
+ return { roots: parseRoots(obj(result, "create result").roots) };
240
+ default:
241
+ if (result !== null)
242
+ throw new DeviceProtocolError(`${op} result must be null`);
243
+ return null;
244
+ }
245
+ }
246
+ /**
247
+ * Parses one WebSocket text frame. `from` is the sender: a device may send `hello` and `res`, the
248
+ * kernel `req` and `cancel`. Throws {@link DeviceProtocolError} on bad JSON, an unknown `type`,
249
+ * missing fields or a frame of the wrong direction.
250
+ */
251
+ export function parseDeviceFrame(raw, from) {
252
+ let o;
253
+ try {
254
+ o = JSON.parse(raw);
255
+ }
256
+ catch {
257
+ throw new DeviceProtocolError("frame is not valid JSON");
258
+ }
259
+ if (!isRecord(o))
260
+ throw new DeviceProtocolError("frame must be a JSON object");
261
+ const type = o.type;
262
+ switch (type) {
263
+ case "hello":
264
+ case "res":
265
+ if (from !== "device")
266
+ throw new DeviceProtocolError(`"${type}" must come from the device`);
267
+ return type === "hello" ? parseHello(o) : parseRes(o);
268
+ case "req":
269
+ case "cancel":
270
+ if (from !== "kernel")
271
+ throw new DeviceProtocolError(`"${type}" must come from the kernel`);
272
+ return type === "req" ? parseReq(o) : { type: "cancel", id: nonEmptyStr(o, "id") };
273
+ default:
274
+ throw new DeviceProtocolError(`unknown frame type "${String(type)}"`);
275
+ }
276
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "0.1.0-rc.5";
1
+ export declare const SDK_VERSION = "0.1.0-rc.6";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -8,6 +8,7 @@ export * from "./connector.js";
8
8
  export * from "./connector-auth.js";
9
9
  export * from "./ctx.js";
10
10
  export * from "./define-extension.js";
11
+ export * from "./device-protocol.js";
11
12
  export * from "./directory.js";
12
13
  export * from "./extension-point.js";
13
14
  export * from "./file-preview.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const SDK_VERSION = "0.1.0-rc.5";
1
+ export const SDK_VERSION = "0.1.0-rc.6";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -8,6 +8,7 @@ export * from "./connector.js";
8
8
  export * from "./connector-auth.js";
9
9
  export * from "./ctx.js";
10
10
  export * from "./define-extension.js";
11
+ export * from "./device-protocol.js";
11
12
  export * from "./directory.js";
12
13
  export * from "./extension-point.js";
13
14
  export * from "./file-preview.js";
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
- export declare function validateSandboxPath(p: string): string[];
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
- export function validateSandboxPath(p) {
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
- errors.push("path must not contain '..' segments");
86
- break;
98
+ return ["path must not contain '..' segments"];
87
99
  }
88
100
  }
89
- if (!SANDBOX_ROOTS.some((r) => p === r || p.startsWith(`${r}/`))) {
90
- errors.push(`path must be under ${SANDBOX_ROOTS.join(", ")}`);
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
  }
@@ -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 `/skills/<skillName>@<digest>` (a qualified `<plugin>:<name>` or a
31
- * digest that is not 12 lowercase hex digits throws a `TypeError`). `args` is trimmed;
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
  /**
@@ -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 `/skills/<skillName>@<digest>` (a qualified `<plugin>:<name>` or a
116
- * digest that is not 12 lowercase hex digits throws a `TypeError`). `args` is trimmed;
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
- /** A skill version's directory in the sandbox: `/skills/<name>@<digest>` ({@link skillDigest}). */
53
- export declare function skillSandboxDir(name: string, digest: string): string;
54
- /** A skill file's path in the sandbox: `/skills/<name>@<digest>/<path>`. */
55
- export declare function skillSandboxPath(name: string, digest: string, path: string): string;
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
- /** A skill version's directory in the sandbox: `/skills/<name>@<digest>` ({@link skillDigest}). */
85
- export function skillSandboxDir(name, digest) {
86
- return `${SKILLS_SANDBOX_ROOT}/${name}@${digest}`;
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: `/skills/<name>@<digest>/<path>`. */
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-sdk",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "Capybara plugin SDK: the types and pure functions plugins are written against",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {