@tcb-sandbox/cli 0.3.7 → 0.3.9
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/CHANGELOG.md +4 -0
- package/README.md +59 -39
- package/dist/bundled-docs.js +9 -6
- package/dist/cli.js +9 -176
- package/dist/sandbox.js +86 -0
- package/dist/serve.js +131 -32
- package/dist/trw-embedded.js +883 -854
- package/docs/README.md +2 -2
- package/docs/local-mode.md +21 -1
- package/docs/quick-start.md +19 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,67 +1,87 @@
|
|
|
1
1
|
# @tcb-sandbox/cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
> **目录位置:** `clis/sandbox-cli/`
|
|
6
|
-
|
|
7
|
-
| 用法 | 说明 |
|
|
8
|
-
|------|------|
|
|
9
|
-
| **本机起服务** | `tcb-sandbox serve`(或 `local`)在本地跑内置 TRW,默认 `http://127.0.0.1:9000`。 |
|
|
10
|
-
| **只当客户端** | 其它子命令都是 **HTTP 薄客户端**,用 `--endpoint` / `--session-id` 连已有 TRW(远端或本机均可)。 |
|
|
11
|
-
|
|
12
|
-
**文档(建议按顺序看):** [快速上手](./docs/quick-start.md) → [薄客户端](./docs/thin-client.md) → [本地 serve](./docs/local-mode.md) → [文档索引](./docs/README.md) → [变更记录](./CHANGELOG.md)
|
|
13
|
-
|
|
14
|
-
---
|
|
3
|
+
TRW 的官方命令行工具:**一种安装,两种用法**。
|
|
15
4
|
|
|
16
5
|
## 安装
|
|
17
6
|
|
|
18
|
-
任选一种全局安装方式:
|
|
19
|
-
|
|
20
7
|
```bash
|
|
21
8
|
npm install -g @tcb-sandbox/cli
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
```bash
|
|
9
|
+
# 或
|
|
25
10
|
pnpm add -g @tcb-sandbox/cli
|
|
26
11
|
```
|
|
27
12
|
|
|
28
|
-
|
|
13
|
+
## 两种用法
|
|
29
14
|
|
|
30
|
-
|
|
15
|
+
| 用法 | 命令 | 说明 |
|
|
16
|
+
|------|------|------|
|
|
17
|
+
| **本地起服务** | `tcb-sandbox serve` | 启动内置 TRW,默认 `http://127.0.0.1:9000` |
|
|
18
|
+
| **只当客户端** | `tcb-sandbox <tool>` | 调用已有 TRW(远端或本地) |
|
|
31
19
|
|
|
32
|
-
##
|
|
20
|
+
## 快速开始
|
|
33
21
|
|
|
34
22
|
```bash
|
|
23
|
+
# 本地模式
|
|
35
24
|
tcb-sandbox serve
|
|
25
|
+
|
|
36
26
|
# 另开终端
|
|
37
27
|
tcb-sandbox --endpoint http://127.0.0.1:9000 --session-id my-session health
|
|
28
|
+
tcb-sandbox --session-id my-session write --path hello.txt --content "world"
|
|
29
|
+
tcb-sandbox --session-id my-session read --path hello.txt
|
|
38
30
|
```
|
|
39
31
|
|
|
40
|
-
|
|
32
|
+
## 环境变量
|
|
41
33
|
|
|
42
|
-
|
|
34
|
+
```bash
|
|
35
|
+
export TRW_ENDPOINT=https://your-gateway.com
|
|
36
|
+
export TRW_SESSION_ID=your-session-id
|
|
43
37
|
|
|
44
|
-
|
|
38
|
+
# 然后可直接使用
|
|
39
|
+
tcb-sandbox health
|
|
40
|
+
tcb-sandbox bash --command "ls -la"
|
|
41
|
+
```
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
- 需要 **node-pty**、**@mongodb-js/zstd**:已写在包的 `dependencies` 里,给内置 bundle 动态加载用。
|
|
48
|
-
- 想用**自己编出来的 TRW** 替代内置文件:设环境变量 **`TCB_SANDBOX_TRW_ENTRY`** 指向你的 `dist/index.js` 等入口(见 [local-mode.md](./docs/local-mode.md))。
|
|
49
|
-
- 公开展示的相关仓库还有 **@tcb-sandbox/e2b-sandbox-adapter** 等;**npm 上搜不到名叫 TRW 的包**是正常的。
|
|
50
|
-
- 连远端 TRW 时**不会**在 CLI 里写死密钥;工具列表优先走 **`GET /api/docs`**(离线时 CLI 可能用内置文档快照)。
|
|
43
|
+
## 命令列表
|
|
51
44
|
|
|
52
|
-
|
|
45
|
+
| 命令 | 说明 |
|
|
46
|
+
|------|------|
|
|
47
|
+
| `serve` / `local` | 启动本地 TRW |
|
|
48
|
+
| `health` | 健康检查 |
|
|
49
|
+
| `read` / `write` / `edit` | 文件操作 |
|
|
50
|
+
| `bash` | 执行命令 |
|
|
51
|
+
| `grep` / `glob` / `ls` | 搜索 |
|
|
52
|
+
| `batch` | 批量执行 |
|
|
53
|
+
| `git-push` | Git 提交 |
|
|
54
|
+
| `add-mcp-servers` / `remove-mcp-servers` / `list-mcp-servers` | MCP 管理 |
|
|
55
|
+
| `mcporter` | mcporter CLI |
|
|
56
|
+
| `capability-*` | 能力管理(6个) |
|
|
57
|
+
| `secrets` | 密钥管理 |
|
|
58
|
+
| `files` | 文件传输 |
|
|
59
|
+
| `preview` | 预览服务 |
|
|
60
|
+
| `pty` | PTY 终端 |
|
|
61
|
+
| `snapshot` | 快照管理 |
|
|
62
|
+
|
|
63
|
+
## 本地模式详情
|
|
53
64
|
|
|
54
|
-
|
|
65
|
+
```bash
|
|
66
|
+
# 默认端口 9000
|
|
67
|
+
tcb-sandbox serve
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
# 指定端口
|
|
70
|
+
tcb-sandbox serve --port 8080
|
|
57
71
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
pnpm --filter @tcb-sandbox/cli run pack:dry-run
|
|
62
|
-
pnpm --filter @tcb-sandbox/cli run pack:safety # 拒绝把 .env、src、tests、*.map 打进包
|
|
63
|
-
pnpm --filter @tcb-sandbox/cli version patch # 或 minor / major
|
|
64
|
-
pnpm --filter @tcb-sandbox/cli publish
|
|
72
|
+
# 使用自定义 TRW 入口(开发用)
|
|
73
|
+
export TCB_SANDBOX_TRW_ENTRY=/path/to/your/dist/index.js
|
|
74
|
+
tcb-sandbox serve
|
|
65
75
|
```
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
## 限制
|
|
78
|
+
|
|
79
|
+
- 除 `serve` 外所有命令都是纯 HTTP 客户端
|
|
80
|
+
- 需要有效的 `endpoint` 和 `session-id`
|
|
81
|
+
- 文件传输受 6MB 限制
|
|
82
|
+
|
|
83
|
+
## 更多文档
|
|
84
|
+
|
|
85
|
+
- `docs/quick-start.md` — 快速上手
|
|
86
|
+
- `docs/local-mode.md` — 本地模式详解
|
|
87
|
+
- `docs/thin-client.md` — 薄客户端说明
|
package/dist/bundled-docs.js
CHANGED
|
@@ -87,10 +87,7 @@ export const BUNDLED_API_DOCS = {
|
|
|
87
87
|
additionalProperties: false,
|
|
88
88
|
properties: {
|
|
89
89
|
serverNames: {
|
|
90
|
-
oneOf: [
|
|
91
|
-
{ type: "array", items: { type: "string" } },
|
|
92
|
-
{ type: "string" },
|
|
93
|
-
],
|
|
90
|
+
oneOf: [{ type: "array", items: { type: "string" } }, { type: "string" }],
|
|
94
91
|
},
|
|
95
92
|
serverName: { type: "string" },
|
|
96
93
|
name: { type: "string" },
|
|
@@ -161,11 +158,17 @@ export const BUNDLED_API_DOCS = {
|
|
|
161
158
|
type: "object",
|
|
162
159
|
required: ["command"],
|
|
163
160
|
properties: {
|
|
164
|
-
command: {
|
|
161
|
+
command: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "Mcporter subcommand (without 'mcporter' prefix)",
|
|
164
|
+
},
|
|
165
165
|
timeout: { type: "integer", description: "Timeout in ms (default 300000)" },
|
|
166
166
|
},
|
|
167
167
|
},
|
|
168
|
-
limits: {
|
|
168
|
+
limits: {
|
|
169
|
+
default_timeout_ms: 300_000,
|
|
170
|
+
allowed_subcommands: ["help", "list", "call", "config"],
|
|
171
|
+
},
|
|
169
172
|
notes: ["Bundled fallback docs snapshot."],
|
|
170
173
|
},
|
|
171
174
|
{
|
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { TcbSandboxClient } from "@tcb-sandbox/sdk-js";
|
|
5
6
|
import { Command } from "commander";
|
|
6
7
|
import { BUNDLED_API_DOCS } from "./bundled-docs.js";
|
|
7
8
|
import { defaultWorkspaceRoot, runServe } from "./serve.js";
|
|
8
|
-
import { TcbSandboxClient } from "@tcb-sandbox/sdk-js";
|
|
9
9
|
const DEFAULT_TIMEOUT = 600_000;
|
|
10
10
|
const DEFAULT_ACCEPT_HEADER = "application/problem+json, application/json;q=0.9, text/markdown;q=0.8, */*;q=0.1";
|
|
11
11
|
const LOG_PRIORITIES = {
|
|
@@ -130,24 +130,6 @@ function logMessage(ctx, level, message) {
|
|
|
130
130
|
}
|
|
131
131
|
process.stderr.write(`[${level.toUpperCase()}] ${message}\n`);
|
|
132
132
|
}
|
|
133
|
-
function parseKeyValue(input) {
|
|
134
|
-
const idx = input.indexOf("=");
|
|
135
|
-
if (idx <= 0)
|
|
136
|
-
throw new Error(`Invalid --param "${input}". Expected key=value.`);
|
|
137
|
-
const key = input.slice(0, idx).trim();
|
|
138
|
-
const raw = input.slice(idx + 1);
|
|
139
|
-
if (!key)
|
|
140
|
-
throw new Error(`Invalid --param "${input}". Key is empty.`);
|
|
141
|
-
if (raw === "true")
|
|
142
|
-
return [key, true];
|
|
143
|
-
if (raw === "false")
|
|
144
|
-
return [key, false];
|
|
145
|
-
if (raw === "null")
|
|
146
|
-
return [key, null];
|
|
147
|
-
if (raw !== "" && /^-?\d+(\.\d+)?$/.test(raw))
|
|
148
|
-
return [key, Number(raw)];
|
|
149
|
-
return [key, raw];
|
|
150
|
-
}
|
|
151
133
|
function normalizeBashModeValue(input) {
|
|
152
134
|
if (typeof input !== "string") {
|
|
153
135
|
throw new Error('Invalid bash mode. Supported values: "execute", "dry_run", "dryrun".');
|
|
@@ -203,112 +185,6 @@ function createSdkClientFromContext(ctx) {
|
|
|
203
185
|
timeoutInSeconds: ctx.timeout > 0 ? Math.floor(ctx.timeout / 1000) : DEFAULT_TIMEOUT / 1000,
|
|
204
186
|
});
|
|
205
187
|
}
|
|
206
|
-
async function request(ctx, method, route, options) {
|
|
207
|
-
const controller = new AbortController();
|
|
208
|
-
const startedAt = Date.now();
|
|
209
|
-
const timer = setTimeout(() => controller.abort(), ctx.timeout);
|
|
210
|
-
const headers = { ...ctx.headers };
|
|
211
|
-
if (options?.accept) {
|
|
212
|
-
headers.Accept = options.accept;
|
|
213
|
-
}
|
|
214
|
-
else if (!Object.keys(headers).some((key) => key.toLowerCase() === "accept")) {
|
|
215
|
-
headers.Accept = DEFAULT_ACCEPT_HEADER;
|
|
216
|
-
}
|
|
217
|
-
let body;
|
|
218
|
-
if (options?.json !== undefined) {
|
|
219
|
-
headers["Content-Type"] = "application/json";
|
|
220
|
-
body = JSON.stringify(options.json);
|
|
221
|
-
}
|
|
222
|
-
else if (options?.body) {
|
|
223
|
-
headers["Content-Type"] = options.contentType ?? "application/octet-stream";
|
|
224
|
-
body = new Uint8Array(options.body);
|
|
225
|
-
}
|
|
226
|
-
logMessage(ctx, "info", `${method} ${route}`);
|
|
227
|
-
if (shouldLog(ctx, "debug")) {
|
|
228
|
-
const maskedHeaders = {};
|
|
229
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
230
|
-
maskedHeaders[k] = maskHeaderValue(k, v);
|
|
231
|
-
}
|
|
232
|
-
logMessage(ctx, "debug", `request headers: ${JSON.stringify(maskedHeaders)}`);
|
|
233
|
-
if (options?.json !== undefined) {
|
|
234
|
-
logMessage(ctx, "debug", `request json: ${JSON.stringify(redactJsonForDebug(options.json))}`);
|
|
235
|
-
}
|
|
236
|
-
else if (options?.body) {
|
|
237
|
-
logMessage(ctx, "debug", `request body bytes: ${options.body.byteLength}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
try {
|
|
241
|
-
const res = await fetch(`${ctx.endpoint}${route}`, {
|
|
242
|
-
method,
|
|
243
|
-
headers,
|
|
244
|
-
body,
|
|
245
|
-
signal: controller.signal,
|
|
246
|
-
});
|
|
247
|
-
const elapsed = Date.now() - startedAt;
|
|
248
|
-
logMessage(ctx, "info", `${method} ${route} -> ${res.status} (${elapsed}ms)`);
|
|
249
|
-
return res;
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
252
|
-
const elapsed = Date.now() - startedAt;
|
|
253
|
-
logMessage(ctx, "error", `${method} ${route} failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`);
|
|
254
|
-
throw err;
|
|
255
|
-
}
|
|
256
|
-
finally {
|
|
257
|
-
clearTimeout(timer);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
function safeParseJson(input) {
|
|
261
|
-
try {
|
|
262
|
-
return JSON.parse(input);
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
return undefined;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
function extractErrorMessage(payload) {
|
|
269
|
-
if (!payload || typeof payload !== "object")
|
|
270
|
-
return undefined;
|
|
271
|
-
const maybe = payload;
|
|
272
|
-
if (typeof maybe.detail === "string" && maybe.detail.trim())
|
|
273
|
-
return maybe.detail;
|
|
274
|
-
if (typeof maybe.error === "string" && maybe.error.trim())
|
|
275
|
-
return maybe.error;
|
|
276
|
-
if (typeof maybe.message === "string" && maybe.message.trim())
|
|
277
|
-
return maybe.message;
|
|
278
|
-
if (typeof maybe.title === "string" && maybe.title.trim())
|
|
279
|
-
return maybe.title;
|
|
280
|
-
return undefined;
|
|
281
|
-
}
|
|
282
|
-
async function throwHttpError(res, requestLabel) {
|
|
283
|
-
const text = await res.text();
|
|
284
|
-
const payload = safeParseJson(text);
|
|
285
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
286
|
-
const message = extractErrorMessage(payload);
|
|
287
|
-
if (payload && typeof payload === "object" && contentType.includes("application/problem+json")) {
|
|
288
|
-
const problem = payload;
|
|
289
|
-
const retryHint = problem.retryable && typeof problem.retry_after === "number"
|
|
290
|
-
? `; retry after ${problem.retry_after}s`
|
|
291
|
-
: problem.retryable
|
|
292
|
-
? "; retryable"
|
|
293
|
-
: "";
|
|
294
|
-
const codeHint = problem.error_code ? ` [${problem.error_code}]` : "";
|
|
295
|
-
throw new Error(`${requestLabel} failed (${res.status})${codeHint}: ${message ?? "Unknown problem response"}${retryHint}`);
|
|
296
|
-
}
|
|
297
|
-
throw new Error(`${requestLabel} failed (${res.status}): ${message ?? (text || "Unknown error response")}`);
|
|
298
|
-
}
|
|
299
|
-
async function readJsonResponse(res, requestLabel) {
|
|
300
|
-
if (!res.ok) {
|
|
301
|
-
return throwHttpError(res, requestLabel);
|
|
302
|
-
}
|
|
303
|
-
let body;
|
|
304
|
-
try {
|
|
305
|
-
body = await res.json();
|
|
306
|
-
}
|
|
307
|
-
catch (err) {
|
|
308
|
-
throw new Error(`${requestLabel} returned non-JSON response: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
-
}
|
|
310
|
-
return body;
|
|
311
|
-
}
|
|
312
188
|
function printJson(data) {
|
|
313
189
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
314
190
|
}
|
|
@@ -316,19 +192,6 @@ function printPretty(title, data) {
|
|
|
316
192
|
process.stdout.write(`${title}\n`);
|
|
317
193
|
printJson(data);
|
|
318
194
|
}
|
|
319
|
-
async function readApiDocs(ctx) {
|
|
320
|
-
const res = await request(ctx, "GET", "/api/docs");
|
|
321
|
-
return readJsonResponse(res, "GET /api/docs");
|
|
322
|
-
}
|
|
323
|
-
async function getDocs(ctx) {
|
|
324
|
-
try {
|
|
325
|
-
return await readApiDocs(ctx);
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
logMessage(ctx, "warn", "GET /api/docs unavailable, using bundled fallback docs");
|
|
329
|
-
return BUNDLED_API_DOCS;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
195
|
async function readFromStdin() {
|
|
333
196
|
const chunks = [];
|
|
334
197
|
for await (const chunk of process.stdin) {
|
|
@@ -336,35 +199,6 @@ async function readFromStdin() {
|
|
|
336
199
|
}
|
|
337
200
|
return Buffer.concat(chunks).toString("utf8");
|
|
338
201
|
}
|
|
339
|
-
function renderSchema(schema) {
|
|
340
|
-
const properties = (schema.properties ?? {});
|
|
341
|
-
const required = new Set((schema.required ?? []));
|
|
342
|
-
const lines = [];
|
|
343
|
-
const keys = Object.keys(properties);
|
|
344
|
-
if (keys.length === 0) {
|
|
345
|
-
lines.push(" (no fields)");
|
|
346
|
-
return lines;
|
|
347
|
-
}
|
|
348
|
-
for (const key of keys) {
|
|
349
|
-
const spec = properties[key];
|
|
350
|
-
const type = spec.type ?? "unknown";
|
|
351
|
-
const desc = spec.description ?? "";
|
|
352
|
-
const mark = required.has(key) ? "required" : "optional";
|
|
353
|
-
const constraint = typeof spec.minimum === "number"
|
|
354
|
-
? `, min=${spec.minimum}`
|
|
355
|
-
: typeof spec.pattern === "string"
|
|
356
|
-
? `, pattern=${spec.pattern}`
|
|
357
|
-
: "";
|
|
358
|
-
lines.push(` - ${key}: ${type} (${mark}${constraint})${desc ? ` — ${desc}` : ""}`);
|
|
359
|
-
}
|
|
360
|
-
return lines;
|
|
361
|
-
}
|
|
362
|
-
function renderRecord(record) {
|
|
363
|
-
const entries = Object.entries(record);
|
|
364
|
-
if (entries.length === 0)
|
|
365
|
-
return [" (none)"];
|
|
366
|
-
return entries.map(([k, v]) => ` - ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
|
|
367
|
-
}
|
|
368
202
|
async function getPreviewUrl(ctx, port) {
|
|
369
203
|
ensureSessionId(ctx);
|
|
370
204
|
const client = createSdkClientFromContext(ctx);
|
|
@@ -382,12 +216,6 @@ async function getPreviewUrl(ctx, port) {
|
|
|
382
216
|
}
|
|
383
217
|
return previewUrl;
|
|
384
218
|
}
|
|
385
|
-
async function callTopLevelTool(ctx, tool, payload, label) {
|
|
386
|
-
const res = await request(ctx, "POST", `/api/tools/${encodeURIComponent(tool)}`, {
|
|
387
|
-
json: payload,
|
|
388
|
-
});
|
|
389
|
-
return readJsonResponse(res, label);
|
|
390
|
-
}
|
|
391
219
|
async function openUrl(url) {
|
|
392
220
|
const { spawn } = await import("node:child_process");
|
|
393
221
|
const platform = process.platform;
|
|
@@ -569,7 +397,9 @@ Error Handling
|
|
|
569
397
|
command,
|
|
570
398
|
timeout: options.timeout,
|
|
571
399
|
cwd: options.cwd,
|
|
572
|
-
mode: options.mode
|
|
400
|
+
mode: options.mode
|
|
401
|
+
? normalizeBashModeValue(options.mode)
|
|
402
|
+
: undefined,
|
|
573
403
|
};
|
|
574
404
|
const response = await client.execution.bash(bashRequest);
|
|
575
405
|
const result = "data" in response ? response.data : response;
|
|
@@ -657,7 +487,8 @@ Error Handling
|
|
|
657
487
|
const payload = options.data ? parseJsonObject(options.data, "--data") : { tool_calls: [] };
|
|
658
488
|
logMessage(ctx, "info", "POST /api/tools/batch");
|
|
659
489
|
const batchRequest = {
|
|
660
|
-
tool_calls: payload.tool_calls ||
|
|
490
|
+
tool_calls: payload.tool_calls ||
|
|
491
|
+
[],
|
|
661
492
|
};
|
|
662
493
|
const response = await client.execution.batch(batchRequest);
|
|
663
494
|
const result = "data" in response ? response.data : response;
|
|
@@ -787,7 +618,9 @@ Error Handling
|
|
|
787
618
|
ensureSessionId(ctx);
|
|
788
619
|
const client = createSdkClientFromContext(ctx);
|
|
789
620
|
logMessage(ctx, "info", "POST /api/tools/capability_list");
|
|
790
|
-
const response = await client.capabilitySystem.capabilityList({
|
|
621
|
+
const response = await client.capabilitySystem.capabilityList({
|
|
622
|
+
revealPaths: options.revealPaths,
|
|
623
|
+
});
|
|
791
624
|
const result = "data" in response ? response.data : response;
|
|
792
625
|
if (ctx.output === "json")
|
|
793
626
|
return printJson(result);
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function isMacOSSandboxAvailable() {
|
|
6
|
+
if (process.platform !== "darwin")
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
execFileSync("/usr/bin/sandbox-exec", ["-p", "(version 1)(allow default)", "/usr/bin/true"], {
|
|
10
|
+
timeout: 5000,
|
|
11
|
+
stdio: "ignore",
|
|
12
|
+
});
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function generateProfile(writablePaths) {
|
|
20
|
+
const lines = [
|
|
21
|
+
";; Auto-generated by @tcb-sandbox/cli — macOS sandbox profile",
|
|
22
|
+
";; Restricts file-write to workspace + system temp directories.",
|
|
23
|
+
"(version 1)",
|
|
24
|
+
"",
|
|
25
|
+
";; Allow everything by default (read, network, process, IPC, …)",
|
|
26
|
+
"(allow default)",
|
|
27
|
+
"",
|
|
28
|
+
";; Deny all file writes globally",
|
|
29
|
+
"(deny file-write*)",
|
|
30
|
+
"",
|
|
31
|
+
";; Re-allow writes to specific paths",
|
|
32
|
+
];
|
|
33
|
+
for (const p of writablePaths) {
|
|
34
|
+
const abs = path.resolve(p);
|
|
35
|
+
lines.push(`(allow file-write* (subpath "${abs}"))`);
|
|
36
|
+
}
|
|
37
|
+
lines.push("");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
export function resolveWritablePaths(workspaceRoot) {
|
|
41
|
+
const paths = [
|
|
42
|
+
path.resolve(workspaceRoot),
|
|
43
|
+
"/tmp",
|
|
44
|
+
"/private/tmp",
|
|
45
|
+
"/dev",
|
|
46
|
+
"/private/var/folders",
|
|
47
|
+
];
|
|
48
|
+
const tmpDir = os.tmpdir();
|
|
49
|
+
if (tmpDir && !paths.some((p) => tmpDir.startsWith(p))) {
|
|
50
|
+
paths.push(tmpDir);
|
|
51
|
+
}
|
|
52
|
+
return paths;
|
|
53
|
+
}
|
|
54
|
+
export function writeProfileFile(workspaceRoot, profile) {
|
|
55
|
+
const sandboxDir = path.join(path.dirname(path.resolve(workspaceRoot)), "sandbox");
|
|
56
|
+
fs.mkdirSync(sandboxDir, { recursive: true });
|
|
57
|
+
const profilePath = path.join(sandboxDir, "serve.sb");
|
|
58
|
+
fs.writeFileSync(profilePath, profile, "utf8");
|
|
59
|
+
return profilePath;
|
|
60
|
+
}
|
|
61
|
+
export function spawnSandboxed(trwMain, env, workspaceRoot) {
|
|
62
|
+
const available = isMacOSSandboxAvailable();
|
|
63
|
+
if (!available) {
|
|
64
|
+
const child = spawn(process.execPath, [trwMain], {
|
|
65
|
+
stdio: "inherit",
|
|
66
|
+
env,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
enabled: false,
|
|
70
|
+
status: "unavailable (not macOS or sandbox-exec missing)",
|
|
71
|
+
child,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const writablePaths = resolveWritablePaths(workspaceRoot);
|
|
75
|
+
const profile = generateProfile(writablePaths);
|
|
76
|
+
const profilePath = writeProfileFile(workspaceRoot, profile);
|
|
77
|
+
const child = spawn("/usr/bin/sandbox-exec", ["-f", profilePath, process.execPath, trwMain], {
|
|
78
|
+
stdio: "inherit",
|
|
79
|
+
env,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
enabled: true,
|
|
83
|
+
status: `active (writes restricted to ${workspaceRoot}, /tmp)`,
|
|
84
|
+
child,
|
|
85
|
+
};
|
|
86
|
+
}
|