@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/README.md +81 -0
- package/dist/index.cjs +308 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +307 -0
- package/dist/protocol-BXejIk9L.cjs +226 -0
- package/dist/protocol-Dj8Yv54w.js +101 -0
- package/dist/worker.cjs +279 -0
- package/dist/worker.d.cts +70 -0
- package/dist/worker.d.ts +70 -0
- package/dist/worker.js +278 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @runko/sandbox-cloudflare
|
|
2
|
+
|
|
3
|
+
把一个 [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/)(真实 Linux 容器)通过一个自部署的 HTTP 网关包成 `RunkoFS & RunkoExec`,一次注入 `createSession(agent, { workspace })`——"模式 A 同源工作区"落在真实云沙盒上的实现(对照 [core-sdk · 技术方案 §4.5a](../../docs/logic/engine/tech/core-sdk.md) / [沙盒工作区(Sandbox Workspace) · 技术方案 §8](../../docs/host/contract/tech/sandbox.md))。
|
|
4
|
+
|
|
5
|
+
与 E2B/Vercel 两个适配器架构不同:Cloudflare Sandbox 只能从 Cloudflare Worker 内部访问(Durable Object binding),没有办法从任意 Node 进程直连——因此本包是**两个入口**:
|
|
6
|
+
|
|
7
|
+
- `.`(本文档的客户端,跑在**任意** Node ≥20 进程):纯 `fetch` 协议客户端,不 import `@cloudflare/sandbox`(该包只能在 workerd 里加载)。
|
|
8
|
+
- `./worker`(网关,部署在**宿主自己的** wrangler 项目里):把协议翻译成对真实 `@cloudflare/sandbox` 的调用。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm add @runko/sandbox-cloudflare
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
客户端侧零 provider SDK——本包不随 `@runko/sdk` 一起装,属于按需显式安装的可选集成。网关侧 `@cloudflare/sandbox` 是本包的 `peerDependency`(版本跟宿主的 wrangler 项目走),装在部署网关的那个 wrangler 项目里,不装在跑 agent 的 Node 进程里。
|
|
17
|
+
|
|
18
|
+
## 网关部署
|
|
19
|
+
|
|
20
|
+
先把网关部署到你自己的 Cloudflare 账号(需要 Workers Paid 计划,无免费层)。一个完整可跑可部署的参考项目见 [apps/cloudflare-worker-server/](../../apps/cloudflare-worker-server/README.md)——自备 CF 账号后 `pnpm --filter @runko-chat/cloudflare-worker-server deploy` 即可部署;本包自身只发布 `createSandboxGateway` 这层纯函数,网关的部署形态维护在该项目里。核心装配只有几行:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// 你的 wrangler 项目的 Worker 入口(见 apps/cloudflare-worker-server/src/index.ts)
|
|
24
|
+
import { getSandbox } from "@cloudflare/sandbox";
|
|
25
|
+
import { createSandboxGateway } from "@runko/sandbox-cloudflare/worker";
|
|
26
|
+
|
|
27
|
+
export { Sandbox } from "@cloudflare/sandbox"; // Durable Object 类必须从 Worker 入口导出
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
async fetch(request: Request, env: { Sandbox: DurableObjectNamespace; RUNKO_GATEWAY_TOKEN: string }) {
|
|
31
|
+
const gateway = createSandboxGateway({
|
|
32
|
+
token: env.RUNKO_GATEWAY_TOKEN,
|
|
33
|
+
getSandbox: (sandboxId) => getSandbox(env.Sandbox, sandboxId),
|
|
34
|
+
});
|
|
35
|
+
return gateway.fetch(request);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 快速上手(客户端)
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { cloudflareWorkspace } from "@runko/sandbox-cloudflare";
|
|
44
|
+
import { createSession, defineAgent } from "@runko/sdk";
|
|
45
|
+
|
|
46
|
+
const workspace = cloudflareWorkspace({
|
|
47
|
+
url: process.env.RUNKO_CF_GATEWAY_URL!, // 部署好的网关 URL
|
|
48
|
+
token: process.env.RUNKO_CF_GATEWAY_TOKEN!, // 与 `wrangler secret put RUNKO_GATEWAY_TOKEN` 相同的值
|
|
49
|
+
});
|
|
50
|
+
const session = createSession(defineAgent({ model: "anthropic/claude-sonnet-5" }), { workspace });
|
|
51
|
+
const result = await session.send("在 notes.txt 里写一句问候语,然后用 bash 验证内容。");
|
|
52
|
+
console.log(result.finalResponse);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
没有 `Sandbox` 风格的 SDK 对象需要创建/销毁——沙盒生命周期由宿主的 wrangler 项目(`getSandbox()` 那一行)管理,不由这个客户端管理。
|
|
56
|
+
|
|
57
|
+
完整可跑示例(含**零部署**的进程内 client → gateway → fake sandbox 完整协议往返演示)见 [examples/src/11-sandbox-cloudflare.ts](../../examples/src/11-sandbox-cloudflare.ts)。
|
|
58
|
+
|
|
59
|
+
## 结构化接口 / BYO
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// 客户端(".")
|
|
63
|
+
function cloudflareWorkspace(opts: { url: string; token: string; sandboxId?: string; fetch?: typeof fetch }): RunkoFS & RunkoExec;
|
|
64
|
+
|
|
65
|
+
// 网关("./worker")
|
|
66
|
+
function createSandboxGateway(opts: { token: string; getSandbox: (sandboxId: string) => CfSandboxLike | Promise<CfSandboxLike> }): {
|
|
67
|
+
fetch(request: Request): Promise<Response>;
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- 客户端没有"实例"概念(E2B/Vercel 是 BYO 一个已创建的沙盒对象;这里 BYO 的是一个已部署的**网关**——一个 URL + token);`fetch` 可注入自定义传输(测试/演示用直连网关 handler,见 examples 11 的进程内演示)。
|
|
72
|
+
- `CfSandboxLike` 是以 `@cloudflare/sandbox@0.12.3` 的 `ISandbox` 为蓝本手写的结构化最小子集(`exec`/`readFile`/`writeFile`/`mkdir`/`deleteFile`/`listFiles`)——`./worker` 运行时零 `import "@cloudflare/sandbox"`,真实装配(`getSandbox(env.Sandbox, id)` 的返回值)天然结构兼容这个接口,留给宿主项目经 `createSandboxGateway({ getSandbox })` 注入。
|
|
73
|
+
- 全部 `POST` + JSON body,`Authorization: Bearer <token>` 鉴权,`x-runko-sandbox` 头选择沙盒(缺省 `"default"`),二进制经 base64,`/exec` 走 NDJSON 流式响应——完整协议见 [沙盒工作区(Sandbox Workspace) · 技术方案 §8.3](../../docs/host/contract/tech/sandbox.md)。
|
|
74
|
+
|
|
75
|
+
## 已知限制(docs/host/contract/tech/sandbox.md §8.2–§8.3 / docs/logic/engine/plans/core-sdk.md P10-3 实际改动,如实照抄不发明)
|
|
76
|
+
|
|
77
|
+
- **路径锚定是隐式的、发生在网关一侧,没有 `root` 配置项**:不同于 E2B/Vercel 的客户端 `opts.root`,这里的虚拟根 `/` 锚定在**沙盒自身的默认工作目录**——网关把客户端送来的虚拟绝对路径去掉前导 `/` 就得到沙盒相对路径,两端都不需要显式配置真实目录。**副作用**:bash 命令里带前导 `/` 的绝对路径(如 `cat /notes.txt`)落在沙盒真实文件系统根,而不是虚拟工作区根——同一份文件要在文件工具和 bash 之间互通,bash 侧要用相对路径。这是"真实 FS + root 锚定"的固有语义,E2B(`/home/user`)/Vercel(`/vercel/sandbox`)同样存在,只是那两家的锚点是客户端可见的配置项,这里锚点藏在网关内部。
|
|
78
|
+
- **`readdir`/`stat` 对 symlink 与其他非常规条目一律归一为 `"file"`**——真实沙盒文件系统没有 `MemoryFS` 那种"reference"概念。
|
|
79
|
+
- **`mtime` 精度是容器真实的文件修改时间**(常见是秒级精度),不是 `MemoryFS` 那种毫秒级递增时间戳。
|
|
80
|
+
- **每次 RunkoFS/RunkoExec 调用都是一次 HTTP 往返**(经 Durable Object,几十到几百毫秒)——扫描类操作(globbing 大目录树、grep-like 搜索)优先用一条 bash 命令(`find`/`grep`),而不是逐个文件工具调用。
|
|
81
|
+
- **沙盒生命周期(idle 睡眠、重启、过期)由宿主的 wrangler 项目管理**,不由这个客户端/网关管理——沙盒不可达时以网关错误的形式从 `exec()`/文件方法浮出,宿主负责唤醒或重建沙盒。沙盒磁盘本身是临时的:Durable Object idle 睡眠(默认 10 分钟)后文件系统丢失(见 [apps/cloudflare-worker-server/README.md](../../apps/cloudflare-worker-server/README.md)),长期数据需要宿主自己接 R2 backup,v1 网关不代理这一层。
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_protocol = require("./protocol-BXejIk9L.cjs");
|
|
3
|
+
let _runko_virtual_fs = require("@runko/virtual-fs");
|
|
4
|
+
require("zod");
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* `.`:Cloudflare Sandbox 网关的纯 fetch 客户端(任意 Node ≥20 进程)——`cloudflareWorkspace(opts)`
|
|
8
|
+
* 返回 `RunkoFS & RunkoExec`,供 `createSession({ workspace })` 一次注入(docs/tech/core-sdk.md §4.5a 模式 A)。
|
|
9
|
+
* 本文件与 `./worker`(`src/worker.ts`)通过 `src/protocol.ts` 的 zod schema 共用同一份 wire 契约;
|
|
10
|
+
* 不 import `@cloudflare/sandbox`——这一侧只说 HTTP,不知道也不需要知道对面跑的是不是真沙盒。
|
|
11
|
+
*/
|
|
12
|
+
const DESCRIBE = [
|
|
13
|
+
"cloudflareWorkspace: Cloudflare Sandbox accessed through a self-deployed HTTP gateway (\"./worker\"'s",
|
|
14
|
+
"createSandboxGateway, running in the host's own wrangler project) — every RunkoFS/RunkoExec call is one",
|
|
15
|
+
"HTTP round trip (tens to hundreds of ms), not an in-process operation.",
|
|
16
|
+
"Real Linux container (Cloudflare Containers). Same-origin workspace (mode A): bash and the file tools read",
|
|
17
|
+
"and write the exact same filesystem, so a bash redirect write is immediately visible to read-file and",
|
|
18
|
+
"vice versa — there is nothing to reconcile.",
|
|
19
|
+
"The virtual root \"/\" is anchored at the sandbox's own default working directory, not the container's real",
|
|
20
|
+
"filesystem root — but this is not a VirtualFS-style security boundary: the container is real Linux, and",
|
|
21
|
+
"anything the sandbox can execute (e.g. bash `cd /`) can reach the real root. Isolation is the boundary,",
|
|
22
|
+
"not path validation.",
|
|
23
|
+
"readdir/stat report type \"file\" for symlinks and other non-regular entries (no \"reference\" concept here);",
|
|
24
|
+
"mtime comes from the container's real file modification time (commonly second-level precision).",
|
|
25
|
+
"Scanning operations (globbing a large tree, grep-like search) cost one HTTP round trip per file tool call —",
|
|
26
|
+
"prefer a single bash call (`find`/`grep`) for anything beyond a handful of files.",
|
|
27
|
+
"Sandbox lifecycle (idle sleep, restarts, expiry) is managed by the host's wrangler project, not by this",
|
|
28
|
+
"adapter — a stopped/unreachable sandbox surfaces as a gateway error from exec()/the file methods; the host",
|
|
29
|
+
"is responsible for waking or recreating it."
|
|
30
|
+
].join(" ");
|
|
31
|
+
const textEncoder = new TextEncoder();
|
|
32
|
+
/**
|
|
33
|
+
* `Uint8Array` ↔ base64 往返,靠全局 `btoa`/`atob`(Node ≥18 与 Cloudflare Workers 都原生提供),
|
|
34
|
+
* 不用 `Buffer`——这一侧刻意保持零 Node-only 便利 API,理由与 `./worker` 零 cloudflare import
|
|
35
|
+
* 同源:本包的两个入口都应该能在对方的运行时环境里被复用而不报错。
|
|
36
|
+
*/
|
|
37
|
+
function bytesToBase64(bytes) {
|
|
38
|
+
let binary = "";
|
|
39
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
40
|
+
return btoa(binary);
|
|
41
|
+
}
|
|
42
|
+
function base64ToBytes(base64) {
|
|
43
|
+
const binary = atob(base64);
|
|
44
|
+
const bytes = new Uint8Array(binary.length);
|
|
45
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
function describeError(error) {
|
|
49
|
+
return error instanceof Error ? error.message : String(error);
|
|
50
|
+
}
|
|
51
|
+
function requestInit(ctx, body) {
|
|
52
|
+
return {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
[require_protocol.AUTH_HEADER]: `Bearer ${ctx.token}`,
|
|
57
|
+
[require_protocol.SANDBOX_ID_HEADER]: ctx.sandboxId
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(body)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function parseErrorBody(res) {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = await res.json();
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
code: "sandbox_error",
|
|
69
|
+
message: `gateway returned ${String(res.status)} ${res.statusText} with a non-JSON body`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const parsed = require_protocol.errorBodySchema.safeParse(raw);
|
|
73
|
+
if (!parsed.success) return {
|
|
74
|
+
code: "sandbox_error",
|
|
75
|
+
message: `gateway returned ${String(res.status)} with an unrecognized error body`
|
|
76
|
+
};
|
|
77
|
+
return parsed.data;
|
|
78
|
+
}
|
|
79
|
+
/** 把网关的 `{code,message}` 翻译成调用方能 `instanceof` 判别的错误——`path` 用调用方本地已知的值,不依赖 wire 回传。 */
|
|
80
|
+
async function fsErrorFromResponse(res, path) {
|
|
81
|
+
const body = await parseErrorBody(res);
|
|
82
|
+
if (body.code === "not_found") return new _runko_virtual_fs.NotFoundError(path);
|
|
83
|
+
if (body.code === "dir_not_empty") return new _runko_virtual_fs.DirectoryNotEmptyError(path);
|
|
84
|
+
return /* @__PURE__ */ new Error(`cloudflare sandbox gateway error (${body.code}): ${body.message}`);
|
|
85
|
+
}
|
|
86
|
+
async function fsCall(ctx, endpoint, req, responseSchema, errorPath) {
|
|
87
|
+
const res = await ctx.fetchImpl(`${ctx.baseUrl}${endpoint}`, requestInit(ctx, req));
|
|
88
|
+
if (!res.ok) throw await fsErrorFromResponse(res, errorPath);
|
|
89
|
+
const json = await res.json();
|
|
90
|
+
return responseSchema.parse(json);
|
|
91
|
+
}
|
|
92
|
+
async function readFileImpl(ctx, path) {
|
|
93
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
94
|
+
const { dataBase64 } = await fsCall(ctx, require_protocol.ENDPOINTS.read, { path: p }, require_protocol.readResponseSchema, p);
|
|
95
|
+
return base64ToBytes(dataBase64);
|
|
96
|
+
}
|
|
97
|
+
async function writeFileImpl(ctx, path, data) {
|
|
98
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
99
|
+
const bytes = typeof data === "string" ? textEncoder.encode(data) : data;
|
|
100
|
+
await fsCall(ctx, require_protocol.ENDPOINTS.write, {
|
|
101
|
+
path: p,
|
|
102
|
+
dataBase64: bytesToBase64(bytes)
|
|
103
|
+
}, require_protocol.okResponseSchema, p);
|
|
104
|
+
}
|
|
105
|
+
async function rmImpl(ctx, path, opts) {
|
|
106
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
107
|
+
await fsCall(ctx, require_protocol.ENDPOINTS.rm, {
|
|
108
|
+
path: p,
|
|
109
|
+
recursive: opts?.recursive
|
|
110
|
+
}, require_protocol.okResponseSchema, p);
|
|
111
|
+
}
|
|
112
|
+
async function mkdirImpl(ctx, path) {
|
|
113
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
114
|
+
await fsCall(ctx, require_protocol.ENDPOINTS.mkdir, { path: p }, require_protocol.okResponseSchema, p);
|
|
115
|
+
}
|
|
116
|
+
async function readdirImpl(ctx, path) {
|
|
117
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
118
|
+
const { entries } = await fsCall(ctx, require_protocol.ENDPOINTS.readdir, { path: p }, require_protocol.readdirResponseSchema, p);
|
|
119
|
+
return entries.map((entry) => ({
|
|
120
|
+
name: entry.name,
|
|
121
|
+
type: entry.type
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
async function statImpl(ctx, path) {
|
|
125
|
+
const p = (0, _runko_virtual_fs.normalizePath)(path);
|
|
126
|
+
const res = await fsCall(ctx, require_protocol.ENDPOINTS.stat, { path: p }, require_protocol.statResponseSchema, p);
|
|
127
|
+
return {
|
|
128
|
+
type: res.type,
|
|
129
|
+
...res.size !== void 0 ? { size: res.size } : {},
|
|
130
|
+
...res.mtime !== void 0 ? { mtime: res.mtime } : {}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function globImpl(ctx, pattern) {
|
|
134
|
+
const { paths } = await fsCall(ctx, require_protocol.ENDPOINTS.glob, { pattern }, require_protocol.globResponseSchema, pattern);
|
|
135
|
+
return paths;
|
|
136
|
+
}
|
|
137
|
+
var CloudflareExecAbortedError = class extends Error {
|
|
138
|
+
constructor(reason) {
|
|
139
|
+
super(reason === "timeout" ? "cloudflareWorkspace: exec timed out" : "cloudflareWorkspace: exec aborted");
|
|
140
|
+
this.name = "CloudflareExecAbortedError";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* 同 mini-bash/just-bash 的 `raceAbort`:`signal` 先触发就立刻 reject,不等 `work` 真正
|
|
145
|
+
* 落定——`work` 可能因为网络分区/网关挂死而长期不 settle,仅靠 fetch 自身的 `signal` 支持
|
|
146
|
+
* 不足以保证"绝不永久挂起"。给 `work` 补一个空 catch,避免它在竞速结束后才 settle 时
|
|
147
|
+
* 产生 unhandled rejection。
|
|
148
|
+
*/
|
|
149
|
+
function raceAbort(work, signal) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const settleAborted = () => {
|
|
152
|
+
work.catch(() => {});
|
|
153
|
+
reject(new CloudflareExecAbortedError("signal"));
|
|
154
|
+
};
|
|
155
|
+
if (signal.aborted) {
|
|
156
|
+
settleAborted();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const onAbort = () => settleAborted();
|
|
160
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
161
|
+
work.then((value) => {
|
|
162
|
+
signal.removeEventListener("abort", onAbort);
|
|
163
|
+
resolve(value);
|
|
164
|
+
}, (error) => {
|
|
165
|
+
signal.removeEventListener("abort", onAbort);
|
|
166
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** NDJSON 逐行解析:必须正确处理一行被 chunk 边界切断的情况——按 `\n` 切分前先攒一个跨 chunk 的 buffer。 */
|
|
171
|
+
async function readExecResultFromStream(res, onOutput, start) {
|
|
172
|
+
if (res.body === null) return {
|
|
173
|
+
exitCode: 1,
|
|
174
|
+
stdout: "",
|
|
175
|
+
stderr: "cloudflareWorkspace: gateway response has no body",
|
|
176
|
+
durationMs: Date.now() - start
|
|
177
|
+
};
|
|
178
|
+
const reader = res.body.getReader();
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
let buffer = "";
|
|
181
|
+
let exitEvent;
|
|
182
|
+
const processLine = (line) => {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (trimmed.length === 0) return;
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = JSON.parse(trimmed);
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const parsed = require_protocol.execStreamEventSchema.safeParse(raw);
|
|
192
|
+
if (!parsed.success) return;
|
|
193
|
+
if (parsed.data.type === "output") onOutput?.({
|
|
194
|
+
stream: parsed.data.stream,
|
|
195
|
+
data: parsed.data.data
|
|
196
|
+
});
|
|
197
|
+
else exitEvent = parsed.data;
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
for (;;) {
|
|
201
|
+
const { value, done } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
buffer += decoder.decode(value, { stream: true });
|
|
204
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
205
|
+
while (newlineIndex !== -1) {
|
|
206
|
+
processLine(buffer.slice(0, newlineIndex));
|
|
207
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
208
|
+
newlineIndex = buffer.indexOf("\n");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
buffer += decoder.decode();
|
|
212
|
+
if (buffer.length > 0) processLine(buffer);
|
|
213
|
+
} finally {
|
|
214
|
+
reader.releaseLock();
|
|
215
|
+
}
|
|
216
|
+
if (exitEvent === void 0) return {
|
|
217
|
+
exitCode: 1,
|
|
218
|
+
stdout: "",
|
|
219
|
+
stderr: "cloudflareWorkspace: gateway stream ended without a final exit event",
|
|
220
|
+
durationMs: Date.now() - start
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
exitCode: exitEvent.exitCode,
|
|
224
|
+
stdout: exitEvent.stdout,
|
|
225
|
+
stderr: exitEvent.stderr,
|
|
226
|
+
durationMs: exitEvent.durationMs
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function execImpl(ctx, req, opts) {
|
|
230
|
+
const start = Date.now();
|
|
231
|
+
const timeoutController = new AbortController();
|
|
232
|
+
const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
|
|
233
|
+
const combined = AbortSignal.any([req.signal, timeoutController.signal]);
|
|
234
|
+
const work = (async () => {
|
|
235
|
+
try {
|
|
236
|
+
const cwd = req.cwd !== void 0 ? (0, _runko_virtual_fs.normalizePath)(req.cwd) : void 0;
|
|
237
|
+
const body = {
|
|
238
|
+
command: req.command,
|
|
239
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
240
|
+
...req.timeoutMs !== void 0 ? { timeoutMs: req.timeoutMs } : {}
|
|
241
|
+
};
|
|
242
|
+
const res = await ctx.fetchImpl(`${ctx.baseUrl}${require_protocol.ENDPOINTS.exec}`, {
|
|
243
|
+
...requestInit(ctx, body),
|
|
244
|
+
signal: combined
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
const errorBody = await parseErrorBody(res);
|
|
248
|
+
return {
|
|
249
|
+
exitCode: 1,
|
|
250
|
+
stdout: "",
|
|
251
|
+
stderr: `cloudflareWorkspace: gateway rejected the exec request (${errorBody.code}): ${errorBody.message}`,
|
|
252
|
+
durationMs: Date.now() - start
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return await readExecResultFromStream(res, opts?.onOutput, start);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return {
|
|
258
|
+
exitCode: 1,
|
|
259
|
+
stdout: "",
|
|
260
|
+
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.`,
|
|
261
|
+
durationMs: Date.now() - start
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
try {
|
|
266
|
+
return await raceAbort(work, combined);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const timedOut = timeoutController.signal.aborted;
|
|
269
|
+
if (error instanceof CloudflareExecAbortedError || timedOut) return {
|
|
270
|
+
exitCode: timedOut ? 124 : 130,
|
|
271
|
+
stdout: "",
|
|
272
|
+
stderr: timedOut ? `cloudflareWorkspace: exec timed out after ${String(req.timeoutMs)}ms` : "cloudflareWorkspace: exec aborted",
|
|
273
|
+
durationMs: Date.now() - start
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
exitCode: 1,
|
|
277
|
+
stdout: "",
|
|
278
|
+
stderr: `cloudflareWorkspace: internal error: ${describeError(error)}`,
|
|
279
|
+
durationMs: Date.now() - start
|
|
280
|
+
};
|
|
281
|
+
} finally {
|
|
282
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function cloudflareWorkspace(opts) {
|
|
286
|
+
const ctx = {
|
|
287
|
+
fetchImpl: opts.fetch ?? globalThis.fetch,
|
|
288
|
+
baseUrl: opts.url.endsWith("/") ? opts.url.slice(0, -1) : opts.url,
|
|
289
|
+
token: opts.token,
|
|
290
|
+
sandboxId: opts.sandboxId ?? "default"
|
|
291
|
+
};
|
|
292
|
+
return {
|
|
293
|
+
defaultApproval: "allow",
|
|
294
|
+
describe() {
|
|
295
|
+
return DESCRIBE;
|
|
296
|
+
},
|
|
297
|
+
readFile: (path) => readFileImpl(ctx, path),
|
|
298
|
+
writeFile: (path, data) => writeFileImpl(ctx, path, data),
|
|
299
|
+
rm: (path, rmOpts) => rmImpl(ctx, path, rmOpts),
|
|
300
|
+
mkdir: (path) => mkdirImpl(ctx, path),
|
|
301
|
+
readdir: (path) => readdirImpl(ctx, path),
|
|
302
|
+
stat: (path) => statImpl(ctx, path),
|
|
303
|
+
glob: (pattern) => globImpl(ctx, pattern),
|
|
304
|
+
exec: (req, execOpts) => execImpl(ctx, req, execOpts)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
exports.cloudflareWorkspace = cloudflareWorkspace;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { RunkoExec, RunkoFS } from "@runko/core";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
interface CloudflareWorkspaceOptions {
|
|
4
|
+
url: string;
|
|
5
|
+
token: string;
|
|
6
|
+
sandboxId?: string;
|
|
7
|
+
/** 注入自定义传输——测试用(直连内存网关 handler)或自定义中间层。默认 `globalThis.fetch`。 */
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
declare function cloudflareWorkspace(opts: CloudflareWorkspaceOptions): RunkoFS & RunkoExec;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { CloudflareWorkspaceOptions, cloudflareWorkspace };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { RunkoExec, RunkoFS } from "@runko/core";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
interface CloudflareWorkspaceOptions {
|
|
4
|
+
url: string;
|
|
5
|
+
token: string;
|
|
6
|
+
sandboxId?: string;
|
|
7
|
+
/** 注入自定义传输——测试用(直连内存网关 handler)或自定义中间层。默认 `globalThis.fetch`。 */
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
declare function cloudflareWorkspace(opts: CloudflareWorkspaceOptions): RunkoFS & RunkoExec;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { CloudflareWorkspaceOptions, cloudflareWorkspace };
|