@runko/sandbox-cloudflare 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,307 @@
1
+ import { _ as readdirResponseSchema, a as SANDBOX_ID_HEADER, b as statResponseSchema, d as globResponseSchema, h as readResponseSchema, l as execStreamEventSchema, o as errorBodySchema, p as okResponseSchema, r as ENDPOINTS, t as AUTH_HEADER } from "./protocol-Dj8Yv54w.js";
2
+ import { DirectoryNotEmptyError, NotFoundError, normalizePath } from "@runko/virtual-fs";
3
+ import "zod";
4
+ //#region src/index.ts
5
+ /**
6
+ * `.`:Cloudflare Sandbox 网关的纯 fetch 客户端(任意 Node ≥20 进程)——`cloudflareWorkspace(opts)`
7
+ * 返回 `RunkoFS & RunkoExec`,供 `createSession({ workspace })` 一次注入(docs/tech/core-sdk.md §4.5a 模式 A)。
8
+ * 本文件与 `./worker`(`src/worker.ts`)通过 `src/protocol.ts` 的 zod schema 共用同一份 wire 契约;
9
+ * 不 import `@cloudflare/sandbox`——这一侧只说 HTTP,不知道也不需要知道对面跑的是不是真沙盒。
10
+ */
11
+ const DESCRIBE = [
12
+ "cloudflareWorkspace: Cloudflare Sandbox accessed through a self-deployed HTTP gateway (\"./worker\"'s",
13
+ "createSandboxGateway, running in the host's own wrangler project) — every RunkoFS/RunkoExec call is one",
14
+ "HTTP round trip (tens to hundreds of ms), not an in-process operation.",
15
+ "Real Linux container (Cloudflare Containers). Same-origin workspace (mode A): bash and the file tools read",
16
+ "and write the exact same filesystem, so a bash redirect write is immediately visible to read-file and",
17
+ "vice versa — there is nothing to reconcile.",
18
+ "The virtual root \"/\" is anchored at the sandbox's own default working directory, not the container's real",
19
+ "filesystem root — but this is not a VirtualFS-style security boundary: the container is real Linux, and",
20
+ "anything the sandbox can execute (e.g. bash `cd /`) can reach the real root. Isolation is the boundary,",
21
+ "not path validation.",
22
+ "readdir/stat report type \"file\" for symlinks and other non-regular entries (no \"reference\" concept here);",
23
+ "mtime comes from the container's real file modification time (commonly second-level precision).",
24
+ "Scanning operations (globbing a large tree, grep-like search) cost one HTTP round trip per file tool call —",
25
+ "prefer a single bash call (`find`/`grep`) for anything beyond a handful of files.",
26
+ "Sandbox lifecycle (idle sleep, restarts, expiry) is managed by the host's wrangler project, not by this",
27
+ "adapter — a stopped/unreachable sandbox surfaces as a gateway error from exec()/the file methods; the host",
28
+ "is responsible for waking or recreating it."
29
+ ].join(" ");
30
+ const textEncoder = new TextEncoder();
31
+ /**
32
+ * `Uint8Array` ↔ base64 往返,靠全局 `btoa`/`atob`(Node ≥18 与 Cloudflare Workers 都原生提供),
33
+ * 不用 `Buffer`——这一侧刻意保持零 Node-only 便利 API,理由与 `./worker` 零 cloudflare import
34
+ * 同源:本包的两个入口都应该能在对方的运行时环境里被复用而不报错。
35
+ */
36
+ function bytesToBase64(bytes) {
37
+ let binary = "";
38
+ for (const byte of bytes) binary += String.fromCharCode(byte);
39
+ return btoa(binary);
40
+ }
41
+ function base64ToBytes(base64) {
42
+ const binary = atob(base64);
43
+ const bytes = new Uint8Array(binary.length);
44
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
45
+ return bytes;
46
+ }
47
+ function describeError(error) {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+ function requestInit(ctx, body) {
51
+ return {
52
+ method: "POST",
53
+ headers: {
54
+ "content-type": "application/json",
55
+ [AUTH_HEADER]: `Bearer ${ctx.token}`,
56
+ [SANDBOX_ID_HEADER]: ctx.sandboxId
57
+ },
58
+ body: JSON.stringify(body)
59
+ };
60
+ }
61
+ async function parseErrorBody(res) {
62
+ let raw;
63
+ try {
64
+ raw = await res.json();
65
+ } catch {
66
+ return {
67
+ code: "sandbox_error",
68
+ message: `gateway returned ${String(res.status)} ${res.statusText} with a non-JSON body`
69
+ };
70
+ }
71
+ const parsed = errorBodySchema.safeParse(raw);
72
+ if (!parsed.success) return {
73
+ code: "sandbox_error",
74
+ message: `gateway returned ${String(res.status)} with an unrecognized error body`
75
+ };
76
+ return parsed.data;
77
+ }
78
+ /** 把网关的 `{code,message}` 翻译成调用方能 `instanceof` 判别的错误——`path` 用调用方本地已知的值,不依赖 wire 回传。 */
79
+ async function fsErrorFromResponse(res, path) {
80
+ const body = await parseErrorBody(res);
81
+ if (body.code === "not_found") return new NotFoundError(path);
82
+ if (body.code === "dir_not_empty") return new DirectoryNotEmptyError(path);
83
+ return /* @__PURE__ */ new Error(`cloudflare sandbox gateway error (${body.code}): ${body.message}`);
84
+ }
85
+ async function fsCall(ctx, endpoint, req, responseSchema, errorPath) {
86
+ const res = await ctx.fetchImpl(`${ctx.baseUrl}${endpoint}`, requestInit(ctx, req));
87
+ if (!res.ok) throw await fsErrorFromResponse(res, errorPath);
88
+ const json = await res.json();
89
+ return responseSchema.parse(json);
90
+ }
91
+ async function readFileImpl(ctx, path) {
92
+ const p = normalizePath(path);
93
+ const { dataBase64 } = await fsCall(ctx, ENDPOINTS.read, { path: p }, readResponseSchema, p);
94
+ return base64ToBytes(dataBase64);
95
+ }
96
+ async function writeFileImpl(ctx, path, data) {
97
+ const p = normalizePath(path);
98
+ const bytes = typeof data === "string" ? textEncoder.encode(data) : data;
99
+ await fsCall(ctx, ENDPOINTS.write, {
100
+ path: p,
101
+ dataBase64: bytesToBase64(bytes)
102
+ }, okResponseSchema, p);
103
+ }
104
+ async function rmImpl(ctx, path, opts) {
105
+ const p = normalizePath(path);
106
+ await fsCall(ctx, ENDPOINTS.rm, {
107
+ path: p,
108
+ recursive: opts?.recursive
109
+ }, okResponseSchema, p);
110
+ }
111
+ async function mkdirImpl(ctx, path) {
112
+ const p = normalizePath(path);
113
+ await fsCall(ctx, ENDPOINTS.mkdir, { path: p }, okResponseSchema, p);
114
+ }
115
+ async function readdirImpl(ctx, path) {
116
+ const p = normalizePath(path);
117
+ const { entries } = await fsCall(ctx, ENDPOINTS.readdir, { path: p }, readdirResponseSchema, p);
118
+ return entries.map((entry) => ({
119
+ name: entry.name,
120
+ type: entry.type
121
+ }));
122
+ }
123
+ async function statImpl(ctx, path) {
124
+ const p = normalizePath(path);
125
+ const res = await fsCall(ctx, ENDPOINTS.stat, { path: p }, statResponseSchema, p);
126
+ return {
127
+ type: res.type,
128
+ ...res.size !== void 0 ? { size: res.size } : {},
129
+ ...res.mtime !== void 0 ? { mtime: res.mtime } : {}
130
+ };
131
+ }
132
+ async function globImpl(ctx, pattern) {
133
+ const { paths } = await fsCall(ctx, ENDPOINTS.glob, { pattern }, globResponseSchema, pattern);
134
+ return paths;
135
+ }
136
+ var CloudflareExecAbortedError = class extends Error {
137
+ constructor(reason) {
138
+ super(reason === "timeout" ? "cloudflareWorkspace: exec timed out" : "cloudflareWorkspace: exec aborted");
139
+ this.name = "CloudflareExecAbortedError";
140
+ }
141
+ };
142
+ /**
143
+ * 同 mini-bash/just-bash 的 `raceAbort`:`signal` 先触发就立刻 reject,不等 `work` 真正
144
+ * 落定——`work` 可能因为网络分区/网关挂死而长期不 settle,仅靠 fetch 自身的 `signal` 支持
145
+ * 不足以保证"绝不永久挂起"。给 `work` 补一个空 catch,避免它在竞速结束后才 settle 时
146
+ * 产生 unhandled rejection。
147
+ */
148
+ function raceAbort(work, signal) {
149
+ return new Promise((resolve, reject) => {
150
+ const settleAborted = () => {
151
+ work.catch(() => {});
152
+ reject(new CloudflareExecAbortedError("signal"));
153
+ };
154
+ if (signal.aborted) {
155
+ settleAborted();
156
+ return;
157
+ }
158
+ const onAbort = () => settleAborted();
159
+ signal.addEventListener("abort", onAbort, { once: true });
160
+ work.then((value) => {
161
+ signal.removeEventListener("abort", onAbort);
162
+ resolve(value);
163
+ }, (error) => {
164
+ signal.removeEventListener("abort", onAbort);
165
+ reject(error instanceof Error ? error : new Error(String(error)));
166
+ });
167
+ });
168
+ }
169
+ /** NDJSON 逐行解析:必须正确处理一行被 chunk 边界切断的情况——按 `\n` 切分前先攒一个跨 chunk 的 buffer。 */
170
+ async function readExecResultFromStream(res, onOutput, start) {
171
+ if (res.body === null) return {
172
+ exitCode: 1,
173
+ stdout: "",
174
+ stderr: "cloudflareWorkspace: gateway response has no body",
175
+ durationMs: Date.now() - start
176
+ };
177
+ const reader = res.body.getReader();
178
+ const decoder = new TextDecoder();
179
+ let buffer = "";
180
+ let exitEvent;
181
+ const processLine = (line) => {
182
+ const trimmed = line.trim();
183
+ if (trimmed.length === 0) return;
184
+ let raw;
185
+ try {
186
+ raw = JSON.parse(trimmed);
187
+ } catch {
188
+ return;
189
+ }
190
+ const parsed = execStreamEventSchema.safeParse(raw);
191
+ if (!parsed.success) return;
192
+ if (parsed.data.type === "output") onOutput?.({
193
+ stream: parsed.data.stream,
194
+ data: parsed.data.data
195
+ });
196
+ else exitEvent = parsed.data;
197
+ };
198
+ try {
199
+ for (;;) {
200
+ const { value, done } = await reader.read();
201
+ if (done) break;
202
+ buffer += decoder.decode(value, { stream: true });
203
+ let newlineIndex = buffer.indexOf("\n");
204
+ while (newlineIndex !== -1) {
205
+ processLine(buffer.slice(0, newlineIndex));
206
+ buffer = buffer.slice(newlineIndex + 1);
207
+ newlineIndex = buffer.indexOf("\n");
208
+ }
209
+ }
210
+ buffer += decoder.decode();
211
+ if (buffer.length > 0) processLine(buffer);
212
+ } finally {
213
+ reader.releaseLock();
214
+ }
215
+ if (exitEvent === void 0) return {
216
+ exitCode: 1,
217
+ stdout: "",
218
+ stderr: "cloudflareWorkspace: gateway stream ended without a final exit event",
219
+ durationMs: Date.now() - start
220
+ };
221
+ return {
222
+ exitCode: exitEvent.exitCode,
223
+ stdout: exitEvent.stdout,
224
+ stderr: exitEvent.stderr,
225
+ durationMs: exitEvent.durationMs
226
+ };
227
+ }
228
+ async function execImpl(ctx, req, opts) {
229
+ const start = Date.now();
230
+ const timeoutController = new AbortController();
231
+ const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
232
+ const combined = AbortSignal.any([req.signal, timeoutController.signal]);
233
+ const work = (async () => {
234
+ try {
235
+ const cwd = req.cwd !== void 0 ? normalizePath(req.cwd) : void 0;
236
+ const body = {
237
+ command: req.command,
238
+ ...cwd !== void 0 ? { cwd } : {},
239
+ ...req.timeoutMs !== void 0 ? { timeoutMs: req.timeoutMs } : {}
240
+ };
241
+ const res = await ctx.fetchImpl(`${ctx.baseUrl}${ENDPOINTS.exec}`, {
242
+ ...requestInit(ctx, body),
243
+ signal: combined
244
+ });
245
+ if (!res.ok) {
246
+ const errorBody = await parseErrorBody(res);
247
+ return {
248
+ exitCode: 1,
249
+ stdout: "",
250
+ stderr: `cloudflareWorkspace: gateway rejected the exec request (${errorBody.code}): ${errorBody.message}`,
251
+ durationMs: Date.now() - start
252
+ };
253
+ }
254
+ return await readExecResultFromStream(res, opts?.onOutput, start);
255
+ } catch (error) {
256
+ return {
257
+ exitCode: 1,
258
+ stdout: "",
259
+ stderr: `cloudflareWorkspace: exec request failed: ${describeError(error)}. If this looks like a network error, check that the gateway worker at "${ctx.baseUrl}" is deployed and reachable, and that url/token/sandboxId are correct.`,
260
+ durationMs: Date.now() - start
261
+ };
262
+ }
263
+ })();
264
+ try {
265
+ return await raceAbort(work, combined);
266
+ } catch (error) {
267
+ const timedOut = timeoutController.signal.aborted;
268
+ if (error instanceof CloudflareExecAbortedError || timedOut) return {
269
+ exitCode: timedOut ? 124 : 130,
270
+ stdout: "",
271
+ stderr: timedOut ? `cloudflareWorkspace: exec timed out after ${String(req.timeoutMs)}ms` : "cloudflareWorkspace: exec aborted",
272
+ durationMs: Date.now() - start
273
+ };
274
+ return {
275
+ exitCode: 1,
276
+ stdout: "",
277
+ stderr: `cloudflareWorkspace: internal error: ${describeError(error)}`,
278
+ durationMs: Date.now() - start
279
+ };
280
+ } finally {
281
+ if (timer !== void 0) clearTimeout(timer);
282
+ }
283
+ }
284
+ function cloudflareWorkspace(opts) {
285
+ const ctx = {
286
+ fetchImpl: opts.fetch ?? globalThis.fetch,
287
+ baseUrl: opts.url.endsWith("/") ? opts.url.slice(0, -1) : opts.url,
288
+ token: opts.token,
289
+ sandboxId: opts.sandboxId ?? "default"
290
+ };
291
+ return {
292
+ defaultApproval: "allow",
293
+ describe() {
294
+ return DESCRIBE;
295
+ },
296
+ readFile: (path) => readFileImpl(ctx, path),
297
+ writeFile: (path, data) => writeFileImpl(ctx, path, data),
298
+ rm: (path, rmOpts) => rmImpl(ctx, path, rmOpts),
299
+ mkdir: (path) => mkdirImpl(ctx, path),
300
+ readdir: (path) => readdirImpl(ctx, path),
301
+ stat: (path) => statImpl(ctx, path),
302
+ glob: (pattern) => globImpl(ctx, pattern),
303
+ exec: (req, execOpts) => execImpl(ctx, req, execOpts)
304
+ };
305
+ }
306
+ //#endregion
307
+ export { cloudflareWorkspace };
@@ -0,0 +1,226 @@
1
+ let zod = require("zod");
2
+ //#region src/protocol.ts
3
+ /**
4
+ * `@runko/sandbox-cloudflare` 网关协议(tech-spec 见 docs/tech/sandbox.md
5
+ * §8.3):`.`(客户端)与 `./worker`(网关)两端共用同一份 zod schema——schema 是
6
+ * wire 契约的唯一事实来源,两端各自 `parse()` 校验后再消费,任何一端改了字段形状
7
+ * 都会在另一端的 parse() 上炸出来,而不是悄悄读到 `undefined`。
8
+ *
9
+ * 全部端点 `POST` + JSON body;鉴权 `Authorization: Bearer <token>`;沙盒选择
10
+ * `x-runko-sandbox: <id>`(缺省 `"default"`);二进制经 base64;错误响应统一
11
+ * `{ code, message }` 形状(`errorBodySchema`),配对应 HTTP 状态码。
12
+ */
13
+ /** 八个端点的路径常量——两端都从这里引用,避免字面量拼写不一致。 */
14
+ const ENDPOINTS = {
15
+ read: "/fs/read",
16
+ write: "/fs/write",
17
+ rm: "/fs/rm",
18
+ mkdir: "/fs/mkdir",
19
+ readdir: "/fs/readdir",
20
+ stat: "/fs/stat",
21
+ glob: "/fs/glob",
22
+ exec: "/exec"
23
+ };
24
+ const AUTH_HEADER = "authorization";
25
+ const SANDBOX_ID_HEADER = "x-runko-sandbox";
26
+ const DEFAULT_SANDBOX_ID = "default";
27
+ const readRequestSchema = zod.z.object({ path: zod.z.string() });
28
+ const readResponseSchema = zod.z.object({ dataBase64: zod.z.string() });
29
+ const writeRequestSchema = zod.z.object({
30
+ path: zod.z.string(),
31
+ dataBase64: zod.z.string()
32
+ });
33
+ /** write/rm/mkdir 共用的成功响应形状。 */
34
+ const okResponseSchema = zod.z.object({ ok: zod.z.literal(true) });
35
+ const rmRequestSchema = zod.z.object({
36
+ path: zod.z.string(),
37
+ recursive: zod.z.boolean().optional()
38
+ });
39
+ const mkdirRequestSchema = zod.z.object({ path: zod.z.string() });
40
+ const readdirRequestSchema = zod.z.object({ path: zod.z.string() });
41
+ /** `RunkoFS.FileStat.type` 的沙盒子集——沙盒工作区没有 reference 条目(§3.1),但
42
+ * 该字面量仍保留在 wire 类型里,让客户端可以直接复用 core 的 `DirEntry`/`FileStat`
43
+ * 类型而不必再窄化一次。 */
44
+ const entryTypeSchema = zod.z.enum([
45
+ "file",
46
+ "dir",
47
+ "reference"
48
+ ]);
49
+ const wireDirEntrySchema = zod.z.object({
50
+ name: zod.z.string(),
51
+ type: entryTypeSchema
52
+ });
53
+ const readdirResponseSchema = zod.z.object({ entries: zod.z.array(wireDirEntrySchema) });
54
+ const statRequestSchema = zod.z.object({ path: zod.z.string() });
55
+ const statResponseSchema = zod.z.object({
56
+ type: entryTypeSchema,
57
+ size: zod.z.number().optional(),
58
+ mtime: zod.z.number().optional()
59
+ });
60
+ const globRequestSchema = zod.z.object({ pattern: zod.z.string() });
61
+ const globResponseSchema = zod.z.object({ paths: zod.z.array(zod.z.string()) });
62
+ const execRequestSchema = zod.z.object({
63
+ command: zod.z.string(),
64
+ cwd: zod.z.string().optional(),
65
+ timeoutMs: zod.z.number().optional()
66
+ });
67
+ const execOutputEventSchema = zod.z.object({
68
+ type: zod.z.literal("output"),
69
+ stream: zod.z.enum(["stdout", "stderr"]),
70
+ data: zod.z.string()
71
+ });
72
+ const execExitEventSchema = zod.z.object({
73
+ type: zod.z.literal("exit"),
74
+ exitCode: zod.z.number(),
75
+ stdout: zod.z.string(),
76
+ stderr: zod.z.string(),
77
+ durationMs: zod.z.number()
78
+ });
79
+ /** NDJSON 逐行的判别联合:N 个 output 块 + 恰好一个终块 exit。 */
80
+ const execStreamEventSchema = zod.z.discriminatedUnion("type", [execOutputEventSchema, execExitEventSchema]);
81
+ const NDJSON_CONTENT_TYPE = "application/x-ndjson";
82
+ /**
83
+ * `not_found`/`not_dir`/`dir_not_empty`/`sandbox_error` 是 §8.3 原文列出的 FS 错误码;
84
+ * `unauthorized`/`bad_request` 是本工单施工时的裁量补充(鉴权失败 401、请求体
85
+ * 解析/校验失败 400 都需要一个结构化 code,而不是把 401/400 的语义硬塞进上面四个
86
+ * FS 专用码里)。
87
+ */
88
+ const errorCodeSchema = zod.z.enum([
89
+ "not_found",
90
+ "not_dir",
91
+ "dir_not_empty",
92
+ "sandbox_error",
93
+ "unauthorized",
94
+ "bad_request"
95
+ ]);
96
+ const errorBodySchema = zod.z.object({
97
+ code: errorCodeSchema,
98
+ message: zod.z.string()
99
+ });
100
+ //#endregion
101
+ Object.defineProperty(exports, "AUTH_HEADER", {
102
+ enumerable: true,
103
+ get: function() {
104
+ return AUTH_HEADER;
105
+ }
106
+ });
107
+ Object.defineProperty(exports, "DEFAULT_SANDBOX_ID", {
108
+ enumerable: true,
109
+ get: function() {
110
+ return DEFAULT_SANDBOX_ID;
111
+ }
112
+ });
113
+ Object.defineProperty(exports, "ENDPOINTS", {
114
+ enumerable: true,
115
+ get: function() {
116
+ return ENDPOINTS;
117
+ }
118
+ });
119
+ Object.defineProperty(exports, "NDJSON_CONTENT_TYPE", {
120
+ enumerable: true,
121
+ get: function() {
122
+ return NDJSON_CONTENT_TYPE;
123
+ }
124
+ });
125
+ Object.defineProperty(exports, "SANDBOX_ID_HEADER", {
126
+ enumerable: true,
127
+ get: function() {
128
+ return SANDBOX_ID_HEADER;
129
+ }
130
+ });
131
+ Object.defineProperty(exports, "errorBodySchema", {
132
+ enumerable: true,
133
+ get: function() {
134
+ return errorBodySchema;
135
+ }
136
+ });
137
+ Object.defineProperty(exports, "execExitEventSchema", {
138
+ enumerable: true,
139
+ get: function() {
140
+ return execExitEventSchema;
141
+ }
142
+ });
143
+ Object.defineProperty(exports, "execRequestSchema", {
144
+ enumerable: true,
145
+ get: function() {
146
+ return execRequestSchema;
147
+ }
148
+ });
149
+ Object.defineProperty(exports, "execStreamEventSchema", {
150
+ enumerable: true,
151
+ get: function() {
152
+ return execStreamEventSchema;
153
+ }
154
+ });
155
+ Object.defineProperty(exports, "globRequestSchema", {
156
+ enumerable: true,
157
+ get: function() {
158
+ return globRequestSchema;
159
+ }
160
+ });
161
+ Object.defineProperty(exports, "globResponseSchema", {
162
+ enumerable: true,
163
+ get: function() {
164
+ return globResponseSchema;
165
+ }
166
+ });
167
+ Object.defineProperty(exports, "mkdirRequestSchema", {
168
+ enumerable: true,
169
+ get: function() {
170
+ return mkdirRequestSchema;
171
+ }
172
+ });
173
+ Object.defineProperty(exports, "okResponseSchema", {
174
+ enumerable: true,
175
+ get: function() {
176
+ return okResponseSchema;
177
+ }
178
+ });
179
+ Object.defineProperty(exports, "readRequestSchema", {
180
+ enumerable: true,
181
+ get: function() {
182
+ return readRequestSchema;
183
+ }
184
+ });
185
+ Object.defineProperty(exports, "readResponseSchema", {
186
+ enumerable: true,
187
+ get: function() {
188
+ return readResponseSchema;
189
+ }
190
+ });
191
+ Object.defineProperty(exports, "readdirRequestSchema", {
192
+ enumerable: true,
193
+ get: function() {
194
+ return readdirRequestSchema;
195
+ }
196
+ });
197
+ Object.defineProperty(exports, "readdirResponseSchema", {
198
+ enumerable: true,
199
+ get: function() {
200
+ return readdirResponseSchema;
201
+ }
202
+ });
203
+ Object.defineProperty(exports, "rmRequestSchema", {
204
+ enumerable: true,
205
+ get: function() {
206
+ return rmRequestSchema;
207
+ }
208
+ });
209
+ Object.defineProperty(exports, "statRequestSchema", {
210
+ enumerable: true,
211
+ get: function() {
212
+ return statRequestSchema;
213
+ }
214
+ });
215
+ Object.defineProperty(exports, "statResponseSchema", {
216
+ enumerable: true,
217
+ get: function() {
218
+ return statResponseSchema;
219
+ }
220
+ });
221
+ Object.defineProperty(exports, "writeRequestSchema", {
222
+ enumerable: true,
223
+ get: function() {
224
+ return writeRequestSchema;
225
+ }
226
+ });
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ //#region src/protocol.ts
3
+ /**
4
+ * `@runko/sandbox-cloudflare` 网关协议(tech-spec 见 docs/tech/sandbox.md
5
+ * §8.3):`.`(客户端)与 `./worker`(网关)两端共用同一份 zod schema——schema 是
6
+ * wire 契约的唯一事实来源,两端各自 `parse()` 校验后再消费,任何一端改了字段形状
7
+ * 都会在另一端的 parse() 上炸出来,而不是悄悄读到 `undefined`。
8
+ *
9
+ * 全部端点 `POST` + JSON body;鉴权 `Authorization: Bearer <token>`;沙盒选择
10
+ * `x-runko-sandbox: <id>`(缺省 `"default"`);二进制经 base64;错误响应统一
11
+ * `{ code, message }` 形状(`errorBodySchema`),配对应 HTTP 状态码。
12
+ */
13
+ /** 八个端点的路径常量——两端都从这里引用,避免字面量拼写不一致。 */
14
+ const ENDPOINTS = {
15
+ read: "/fs/read",
16
+ write: "/fs/write",
17
+ rm: "/fs/rm",
18
+ mkdir: "/fs/mkdir",
19
+ readdir: "/fs/readdir",
20
+ stat: "/fs/stat",
21
+ glob: "/fs/glob",
22
+ exec: "/exec"
23
+ };
24
+ const AUTH_HEADER = "authorization";
25
+ const SANDBOX_ID_HEADER = "x-runko-sandbox";
26
+ const DEFAULT_SANDBOX_ID = "default";
27
+ const readRequestSchema = z.object({ path: z.string() });
28
+ const readResponseSchema = z.object({ dataBase64: z.string() });
29
+ const writeRequestSchema = z.object({
30
+ path: z.string(),
31
+ dataBase64: z.string()
32
+ });
33
+ /** write/rm/mkdir 共用的成功响应形状。 */
34
+ const okResponseSchema = z.object({ ok: z.literal(true) });
35
+ const rmRequestSchema = z.object({
36
+ path: z.string(),
37
+ recursive: z.boolean().optional()
38
+ });
39
+ const mkdirRequestSchema = z.object({ path: z.string() });
40
+ const readdirRequestSchema = z.object({ path: z.string() });
41
+ /** `RunkoFS.FileStat.type` 的沙盒子集——沙盒工作区没有 reference 条目(§3.1),但
42
+ * 该字面量仍保留在 wire 类型里,让客户端可以直接复用 core 的 `DirEntry`/`FileStat`
43
+ * 类型而不必再窄化一次。 */
44
+ const entryTypeSchema = z.enum([
45
+ "file",
46
+ "dir",
47
+ "reference"
48
+ ]);
49
+ const wireDirEntrySchema = z.object({
50
+ name: z.string(),
51
+ type: entryTypeSchema
52
+ });
53
+ const readdirResponseSchema = z.object({ entries: z.array(wireDirEntrySchema) });
54
+ const statRequestSchema = z.object({ path: z.string() });
55
+ const statResponseSchema = z.object({
56
+ type: entryTypeSchema,
57
+ size: z.number().optional(),
58
+ mtime: z.number().optional()
59
+ });
60
+ const globRequestSchema = z.object({ pattern: z.string() });
61
+ const globResponseSchema = z.object({ paths: z.array(z.string()) });
62
+ const execRequestSchema = z.object({
63
+ command: z.string(),
64
+ cwd: z.string().optional(),
65
+ timeoutMs: z.number().optional()
66
+ });
67
+ const execOutputEventSchema = z.object({
68
+ type: z.literal("output"),
69
+ stream: z.enum(["stdout", "stderr"]),
70
+ data: z.string()
71
+ });
72
+ const execExitEventSchema = z.object({
73
+ type: z.literal("exit"),
74
+ exitCode: z.number(),
75
+ stdout: z.string(),
76
+ stderr: z.string(),
77
+ durationMs: z.number()
78
+ });
79
+ /** NDJSON 逐行的判别联合:N 个 output 块 + 恰好一个终块 exit。 */
80
+ const execStreamEventSchema = z.discriminatedUnion("type", [execOutputEventSchema, execExitEventSchema]);
81
+ const NDJSON_CONTENT_TYPE = "application/x-ndjson";
82
+ /**
83
+ * `not_found`/`not_dir`/`dir_not_empty`/`sandbox_error` 是 §8.3 原文列出的 FS 错误码;
84
+ * `unauthorized`/`bad_request` 是本工单施工时的裁量补充(鉴权失败 401、请求体
85
+ * 解析/校验失败 400 都需要一个结构化 code,而不是把 401/400 的语义硬塞进上面四个
86
+ * FS 专用码里)。
87
+ */
88
+ const errorCodeSchema = z.enum([
89
+ "not_found",
90
+ "not_dir",
91
+ "dir_not_empty",
92
+ "sandbox_error",
93
+ "unauthorized",
94
+ "bad_request"
95
+ ]);
96
+ const errorBodySchema = z.object({
97
+ code: errorCodeSchema,
98
+ message: z.string()
99
+ });
100
+ //#endregion
101
+ export { readdirResponseSchema as _, SANDBOX_ID_HEADER as a, statResponseSchema as b, execRequestSchema as c, globResponseSchema as d, mkdirRequestSchema as f, readdirRequestSchema as g, readResponseSchema as h, NDJSON_CONTENT_TYPE as i, execStreamEventSchema as l, readRequestSchema as m, DEFAULT_SANDBOX_ID as n, errorBodySchema as o, okResponseSchema as p, ENDPOINTS as r, execExitEventSchema as s, AUTH_HEADER as t, globRequestSchema as u, rmRequestSchema as v, writeRequestSchema as x, statRequestSchema as y };