@yep-ai/cli 1.0.0 → 1.0.1
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 +31 -28
- package/lib/device/client.js +68 -7
- package/lib/device/tools.js +96 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
## 安装与使用
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
|
|
9
|
-
yep --help
|
|
8
|
+
npx --yes @yep-ai/cli@latest --help
|
|
10
9
|
```
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
通过 npx 直接运行 npm 最新版,无需全局安装:
|
|
13
12
|
|
|
14
13
|
```sh
|
|
15
|
-
npx --yes @yep-ai/cli --help
|
|
16
|
-
npx --yes @yep-ai/cli device start --server https://yep.nihao.cool
|
|
14
|
+
npx --yes @yep-ai/cli@latest --help
|
|
15
|
+
npx --yes @yep-ai/cli@latest device start --server https://yep.nihao.cool
|
|
17
16
|
```
|
|
18
17
|
|
|
19
18
|
本包包含完整实现。`yep-cli` 是计划中的同版本入口包,当前尚未发布本项目版本,请使用 `@yep-ai/cli`。
|
|
@@ -26,31 +25,31 @@ npx --yes @yep-ai/cli device start --server https://yep.nihao.cool
|
|
|
26
25
|
npm install
|
|
27
26
|
npm run yep -- --help
|
|
28
27
|
npm run pack:cli
|
|
29
|
-
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.
|
|
28
|
+
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.1.tgz ./dist/cli/unscoped/yep-cli-1.0.1.tgz
|
|
30
29
|
./tmp/yep-install/node_modules/.bin/yep --version
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
完整实现包为 `scoped/yep-ai-cli-1.0.
|
|
32
|
+
完整实现包为 `scoped/yep-ai-cli-1.0.1.tgz`,入口包为 `unscoped/yep-cli-1.0.1.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
|
|
34
33
|
|
|
35
34
|
## 市场与 Git 内容版本
|
|
36
35
|
|
|
37
36
|
```sh
|
|
38
|
-
yep login --registry https://yep.nihao.cool
|
|
39
|
-
yep whoami
|
|
40
|
-
yep search assistant
|
|
41
|
-
yep info @creator/demo
|
|
42
|
-
yep client set codex,claude
|
|
43
|
-
yep install @creator/demo --client codex
|
|
44
|
-
yep list
|
|
45
|
-
yep list --remote
|
|
46
|
-
yep list --mine
|
|
47
|
-
yep uninstall @creator/demo --client codex
|
|
48
|
-
yep publish ./my-skill
|
|
49
|
-
yep history @creator/demo
|
|
50
|
-
yep diff @creator/demo --base published
|
|
51
|
-
yep publish ./my-agent --kind agent
|
|
52
|
-
yep submit @creator/demo --kind agent
|
|
53
|
-
yep withdraw @creator/demo --kind agent
|
|
37
|
+
npx --yes @yep-ai/cli@latest login --registry https://yep.nihao.cool
|
|
38
|
+
npx --yes @yep-ai/cli@latest whoami
|
|
39
|
+
npx --yes @yep-ai/cli@latest search assistant
|
|
40
|
+
npx --yes @yep-ai/cli@latest info @creator/demo
|
|
41
|
+
npx --yes @yep-ai/cli@latest client set codex,claude
|
|
42
|
+
npx --yes @yep-ai/cli@latest install @creator/demo --client codex
|
|
43
|
+
npx --yes @yep-ai/cli@latest list
|
|
44
|
+
npx --yes @yep-ai/cli@latest list --remote
|
|
45
|
+
npx --yes @yep-ai/cli@latest list --mine
|
|
46
|
+
npx --yes @yep-ai/cli@latest uninstall @creator/demo --client codex
|
|
47
|
+
npx --yes @yep-ai/cli@latest publish ./my-skill
|
|
48
|
+
npx --yes @yep-ai/cli@latest history @creator/demo
|
|
49
|
+
npx --yes @yep-ai/cli@latest diff @creator/demo --base published
|
|
50
|
+
npx --yes @yep-ai/cli@latest publish ./my-agent --kind agent
|
|
51
|
+
npx --yes @yep-ai/cli@latest submit @creator/demo --kind agent
|
|
52
|
+
npx --yes @yep-ai/cli@latest withdraw @creator/demo --kind agent
|
|
54
53
|
```
|
|
55
54
|
|
|
56
55
|
默认资源类型为 `skill`,也支持 `--kind agent|plugin`。Skill 私有保存后从网页申请共享审核;Agent/Plugin 的发布会提交审核。审核通过才进入公开市场。`history` 和 `diff` 访问服务端保留的真实 Git 版本。
|
|
@@ -60,14 +59,18 @@ yep withdraw @creator/demo --kind agent
|
|
|
60
59
|
## 设备节点
|
|
61
60
|
|
|
62
61
|
```sh
|
|
63
|
-
yep device login --server https://yep.nihao.cool
|
|
64
|
-
yep device start "/path/to/documents" --server https://yep.nihao.cool
|
|
65
|
-
yep device status
|
|
66
|
-
yep device logout
|
|
62
|
+
npx --yes @yep-ai/cli@latest device login --server https://yep.nihao.cool
|
|
63
|
+
npx --yes @yep-ai/cli@latest device start "/path/to/documents" --server https://yep.nihao.cool
|
|
64
|
+
npx --yes @yep-ai/cli@latest device status
|
|
65
|
+
npx --yes @yep-ai/cli@latest device logout
|
|
67
66
|
```
|
|
68
67
|
|
|
69
68
|
设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5 个目录;指定目录时仅提供受限内置命令。不指定目录时,命令拥有当前系统用户的 shell 权限,可访问其他文件和网络。进程退出后节点离线,不安装后台服务。设备授权最长 24 小时,可以从网页或 CLI 撤销。
|
|
70
69
|
|
|
70
|
+
节点上线后,可在「设备节点」点击「打开终端」,直接选择启动目录并发送命令,无需创建咨询。终端实时展示标准输出、错误输出和退出码,可以停止命令、清屏和重连。`cd 子目录`、`cd ..` 与 `cd` 可切换工作目录;每条命令独立执行,shell 变量不会跨命令保留。CLI 终端不提供交互式输入,`vim`、`top`、口令提示等需要真实终端的程序请使用 SSH 网页终端。单条命令最长运行 30 秒、输出最多 64 KiB;关闭终端、撤销授权或断线会停止正在执行的命令。
|
|
71
|
+
|
|
72
|
+
指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes @yep-ai/cli@latest`,保留原目录参数与 `--server` 地址。
|
|
73
|
+
|
|
71
74
|
`--server` 只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站;令牌按源站隔离。市场登录不等于设备授权。
|
|
72
75
|
|
|
73
76
|
## 配置
|
|
@@ -86,4 +89,4 @@ yep device logout
|
|
|
86
89
|
|
|
87
90
|
市场凭据保存为 `~/.config/yep/config.json`;设备身份和各源站的授权保存在 `~/.config/yep/devices/`。目录权限 `0700`、凭据文件 `0600`;Windows 使用当前用户专属 ACL。市场和设备令牌分别管理。域名改变后需要在新源站重新登录、配对,不会把旧源站令牌发送到新地址。
|
|
88
91
|
|
|
89
|
-
执行 `yep --help` 或 `yep device --help` 查看全部选项。
|
|
92
|
+
执行 `npx --yes @yep-ai/cli@latest --help` 或 `npx --yes @yep-ai/cli@latest device --help` 查看全部选项。
|
package/lib/device/client.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import WebSocket from "ws";
|
|
7
|
-
import { rootDescriptor, resolveSafe, list, execute } from "./tools.js";
|
|
7
|
+
import { rootDescriptor, resolveSafe, list, execute, terminalDirectory } from "./tools.js";
|
|
8
8
|
const PROTOCOL = 2;
|
|
9
9
|
const DEFAULT_SERVER = "https://yep.nihao.cool";
|
|
10
10
|
function serverOrigin(value) {
|
|
@@ -185,16 +185,18 @@ async function systemRoots(privateDir) {
|
|
|
185
185
|
}));
|
|
186
186
|
}
|
|
187
187
|
class DeviceAgent {
|
|
188
|
-
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt } = {}) {
|
|
188
|
+
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt, onTerminalOutput } = {}) {
|
|
189
189
|
this.roots = roots;
|
|
190
190
|
this.restricted = restricted;
|
|
191
191
|
this.privateDir = privateDir;
|
|
192
192
|
this.token = token;
|
|
193
193
|
this.connectionId = connectionId;
|
|
194
194
|
this.expiresAt = expiresAt;
|
|
195
|
+
this.onTerminalOutput = onTerminalOutput;
|
|
195
196
|
this.closed = false;
|
|
196
197
|
this.bindings = /* @__PURE__ */ new Map();
|
|
197
198
|
this.active = /* @__PURE__ */ new Map();
|
|
199
|
+
this.terminalSessions = new Map();
|
|
198
200
|
}
|
|
199
201
|
publicRoots() {
|
|
200
202
|
return this.roots.map(({ id, name }) => ({ id, name }));
|
|
@@ -202,7 +204,7 @@ class DeviceAgent {
|
|
|
202
204
|
cancel(frame = {}) {
|
|
203
205
|
const waiting = [];
|
|
204
206
|
for (const [id, op] of this.active) {
|
|
205
|
-
if (!frame.id && !frame.roundId && !frame.bindingId || frame.id === id || frame.roundId === op.roundId || frame.bindingId === op.bindingId) {
|
|
207
|
+
if (frame.sessionId ? frame.sessionId === op.sessionId && (!frame.id || frame.id === id) : !frame.id && !frame.roundId && !frame.bindingId || frame.id && frame.id === id || frame.roundId && frame.roundId === op.roundId || frame.bindingId && frame.bindingId === op.bindingId) {
|
|
206
208
|
op.abort.abort();
|
|
207
209
|
waiting.push(op.done);
|
|
208
210
|
}
|
|
@@ -213,6 +215,62 @@ class DeviceAgent {
|
|
|
213
215
|
this.closed = true;
|
|
214
216
|
this.cancel();
|
|
215
217
|
this.bindings.clear();
|
|
218
|
+
for (const session of this.terminalSessions.values()) session.closed = true;
|
|
219
|
+
this.terminalSessions.clear();
|
|
220
|
+
}
|
|
221
|
+
async terminalFrame(frame) {
|
|
222
|
+
const base = { id: frame.id, sessionId: frame.sessionId };
|
|
223
|
+
if (typeof frame.sessionId !== "string" || !frame.sessionId || frame.sessionId.length > 200) throw new Error("Invalid terminal session");
|
|
224
|
+
if (["terminal_cancel", "terminal_close"].includes(frame.type)) {
|
|
225
|
+
if (frame.type === "terminal_close") {
|
|
226
|
+
const session = this.terminalSessions.get(frame.sessionId);
|
|
227
|
+
if (session) session.closed = true;
|
|
228
|
+
this.terminalSessions.delete(frame.sessionId);
|
|
229
|
+
}
|
|
230
|
+
await this.cancel({ sessionId: frame.sessionId, ...(frame.type === "terminal_cancel" && frame.id ? { id: frame.id } : {}) });
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (typeof frame.id !== "string" || !frame.id || frame.id.length > 200 || this.active.has(frame.id) || this.active.size >= 8) throw new Error("Too many or duplicate local operations");
|
|
234
|
+
if (typeof frame.command !== "string" || !frame.command.trim() || frame.command.length > 4096 || frame.command.includes("\0")) throw new Error("Invalid command");
|
|
235
|
+
const root = this.roots.find(item => item.id === frame.folderId);
|
|
236
|
+
if (!root) throw new Error("Unknown terminal root");
|
|
237
|
+
let session = this.terminalSessions.get(frame.sessionId);
|
|
238
|
+
if (!session) {
|
|
239
|
+
if (this.terminalSessions.size >= 8) throw new Error("Too many terminal sessions");
|
|
240
|
+
this.terminalSessions.set(frame.sessionId, session = { closed: false });
|
|
241
|
+
}
|
|
242
|
+
if ([...this.active.values()].some(op => op.sessionId === frame.sessionId)) throw new Error("Terminal command is still running");
|
|
243
|
+
const abort = new AbortController();
|
|
244
|
+
let complete;
|
|
245
|
+
const done = new Promise(resolve => { complete = resolve; });
|
|
246
|
+
this.active.set(frame.id, { abort, sessionId: frame.sessionId, done });
|
|
247
|
+
const valid = () => !this.closed && !session.closed && !abort.signal.aborted;
|
|
248
|
+
try {
|
|
249
|
+
const cwd = frame.path ?? "";
|
|
250
|
+
const changed = await terminalDirectory(root, cwd, frame.command, this.restricted);
|
|
251
|
+
if (!valid()) throw new Error("Operation canceled");
|
|
252
|
+
const output = changed !== null ? { stdout: "", stderr: "", exitCode: 0 } : await execute(root, { operation: "exec", path: cwd, command: frame.command }, {
|
|
253
|
+
restricted: this.restricted, signal: abort.signal, secrets: [this.token],
|
|
254
|
+
onOutput: (stream, data) => {
|
|
255
|
+
if (!valid()) return;
|
|
256
|
+
for (let offset = 0; offset < data.length;) {
|
|
257
|
+
let end = Math.min(data.length, offset + 4096);
|
|
258
|
+
const last = data.charCodeAt(end - 1);
|
|
259
|
+
if (end < data.length && last >= 0xd800 && last <= 0xdbff) end--;
|
|
260
|
+
this.onTerminalOutput?.({ ...base, type: "terminal_output", stream, data: data.slice(offset, end) });
|
|
261
|
+
offset = end;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (!valid()) throw new Error("Operation canceled");
|
|
266
|
+
return { ...base, type: "terminal_result", folderId: root.id, path: changed ?? cwd, output };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (abort.signal.aborted) return { ...base, type: "terminal_result", folderId: root.id, path: frame.path ?? "", output: error.output ?? { stdout: "", stderr: "", exitCode: null, canceled: true } };
|
|
269
|
+
throw error;
|
|
270
|
+
} finally {
|
|
271
|
+
this.active.delete(frame.id);
|
|
272
|
+
complete();
|
|
273
|
+
}
|
|
216
274
|
}
|
|
217
275
|
async frame(frame) {
|
|
218
276
|
if (this.closed) throw new Error("Stale device connection");
|
|
@@ -220,6 +278,7 @@ class DeviceAgent {
|
|
|
220
278
|
this.close();
|
|
221
279
|
throw new Error("Device authorization expired");
|
|
222
280
|
}
|
|
281
|
+
if (["terminal_exec", "terminal_cancel", "terminal_close"].includes(frame.type)) return this.terminalFrame(frame);
|
|
223
282
|
const base = { id: frame.id, consultationId: frame.consultationId, bindingId: frame.bindingId, roundId: frame.roundId };
|
|
224
283
|
if (frame.type === "cancel") {
|
|
225
284
|
await this.cancel(frame);
|
|
@@ -303,7 +362,9 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
303
362
|
const url = new URL("/api/local-cli/connect", server);
|
|
304
363
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
305
364
|
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${credential.token}` }, maxPayload: 15 * 1024 * 1024, handshakeTimeout: 15e3, followRedirects: false });
|
|
306
|
-
const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt
|
|
365
|
+
const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt,
|
|
366
|
+
onTerminalOutput: frame => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame)); },
|
|
367
|
+
});
|
|
307
368
|
let ready = false;
|
|
308
369
|
let heartbeat;
|
|
309
370
|
const cleanup = () => {
|
|
@@ -318,7 +379,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
318
379
|
};
|
|
319
380
|
signal?.addEventListener("abort", abort, { once: true });
|
|
320
381
|
ws.on("open", () => {
|
|
321
|
-
ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true }));
|
|
382
|
+
ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true, capabilities: ["terminal"] }));
|
|
322
383
|
});
|
|
323
384
|
ws.on("unexpected-response", (_req, res) => {
|
|
324
385
|
if ([401, 403].includes(res.statusCode)) {
|
|
@@ -336,7 +397,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
336
397
|
agent.connectionId = frame.connectionId;
|
|
337
398
|
ready = true;
|
|
338
399
|
delay = 1e3;
|
|
339
|
-
console.log("
|
|
400
|
+
console.log("设备已上线。可在网页设备节点中打开终端,或在咨询中选择目录;退出此进程将断开。");
|
|
340
401
|
heartbeat = setInterval(() => {
|
|
341
402
|
if (Date.now() >= Date.parse(credential.expiresAt)) {
|
|
342
403
|
fatal = new Error("Device authorization expired");
|
|
@@ -367,7 +428,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
367
428
|
if (frame?.id && ws.readyState === WebSocket.OPEN) {
|
|
368
429
|
let error = String(e.message).split(credential.token).join("[REDACTED]").split(config.directory).join("[私有目录]");
|
|
369
430
|
for (const root of [...agent.roots].sort((a, b) => b.absolute.length - a.absolute.length)) error = error.split(root.absolute).join("[授权目录]");
|
|
370
|
-
ws.send(JSON.stringify({ type: "result", id: frame.id, bindingId: frame.bindingId, consultationId: frame.consultationId, error: error.slice(0, 8192) }));
|
|
431
|
+
ws.send(JSON.stringify({ type: frame.type?.startsWith("terminal_") ? "terminal_error" : "result", id: frame.id, sessionId: frame.sessionId, bindingId: frame.bindingId, consultationId: frame.consultationId, error: error.slice(0, 8192) }));
|
|
371
432
|
} else {
|
|
372
433
|
fatal = e;
|
|
373
434
|
ws.close();
|
package/lib/device/tools.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import { constants as FS } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
6
|
const MAX_FILE = 10 * 1024 * 1024;
|
|
6
7
|
const MAX_OUTPUT = 64 * 1024;
|
|
7
8
|
function safeRelative(value = "") {
|
|
@@ -189,19 +190,92 @@ async function builtin(root, cwd, command) {
|
|
|
189
190
|
if (cmd === "wc" && args.length === 2 && args[0] === "-l") return { text: String((await read(args[1])).split("\n").length - 1) + "\n" };
|
|
190
191
|
throw new Error("Unsupported restricted command");
|
|
191
192
|
}
|
|
192
|
-
|
|
193
|
+
function parseTerminalDirectory(command, windows = process.platform === "win32") {
|
|
194
|
+
if (!/^\s*cd(?:\s|$)/iu.test(command)) return null;
|
|
195
|
+
if (/[\0\r\n]/u.test(command)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
196
|
+
let target = command.trim().slice(2).trim();
|
|
197
|
+
if (windows && /^\/d(?:\s|$)/iu.test(target)) target = target.slice(2).trim();
|
|
198
|
+
if (!target) return "";
|
|
199
|
+
const quote = target[0];
|
|
200
|
+
if (quote === '"' || quote === "'") {
|
|
201
|
+
// A directory name is interpreted literally, never as shell source.
|
|
202
|
+
// Windows backslashes must survive quotation rather than become escapes.
|
|
203
|
+
if (target.at(-1) !== quote || target.slice(1, -1).includes(quote)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
204
|
+
return target.slice(1, -1);
|
|
205
|
+
}
|
|
206
|
+
if (/[\s|&;<>$`%!*?()[\]{}"']/u.test(target)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
207
|
+
return target;
|
|
208
|
+
}
|
|
209
|
+
async function terminalDirectory(root, cwd, command, restricted) {
|
|
210
|
+
let target;
|
|
211
|
+
try {
|
|
212
|
+
target = parseTerminalDirectory(command);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Only a standalone literal cd changes the simulated terminal directory.
|
|
215
|
+
// In system mode, compound commands and shell expressions run normally in
|
|
216
|
+
// their own shell, whose working directory lasts for that command only.
|
|
217
|
+
if (!restricted) return null;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (target === null) return null;
|
|
221
|
+
await resolveSafe(root, cwd, "directory");
|
|
222
|
+
if (!restricted && (target === "~" || target.startsWith("~/"))) {
|
|
223
|
+
target = path.join(process.env.USERPROFILE || process.env.HOME || root.absolute, target.slice(2));
|
|
224
|
+
}
|
|
225
|
+
let relative;
|
|
226
|
+
if (path.isAbsolute(target)) {
|
|
227
|
+
if (restricted) throw new Error("Use a relative directory inside the authorized root");
|
|
228
|
+
relative = path.relative(root.absolute, path.resolve(target)).split(path.sep).join("/");
|
|
229
|
+
} else {
|
|
230
|
+
if (process.platform === "win32") target = target.replaceAll("\\", "/");
|
|
231
|
+
relative = target ? path.posix.normalize([cwd, target].filter(Boolean).join("/")) : "";
|
|
232
|
+
}
|
|
233
|
+
if (relative === ".") relative = "";
|
|
234
|
+
await resolveSafe(root, relative, "directory");
|
|
235
|
+
return relative;
|
|
236
|
+
}
|
|
237
|
+
// Retain an unfinished secret prefix across chunks so streaming cannot expose a
|
|
238
|
+
// token that happened to cross a stdout/stderr packet boundary.
|
|
239
|
+
function outputRedactor(secrets, emit) {
|
|
240
|
+
const values = [...new Set(secrets.filter(value => typeof value === "string" && value))].sort((a, b) => b.length - a.length);
|
|
241
|
+
let pending = "";
|
|
242
|
+
return (text, final = false, truncated = false) => {
|
|
243
|
+
pending += text;
|
|
244
|
+
for (const secret of values) pending = pending.split(secret).join("[REDACTED]");
|
|
245
|
+
let tail = 0;
|
|
246
|
+
for (const secret of values) {
|
|
247
|
+
for (let length = Math.min(secret.length - 1, pending.length); length > tail; length--) {
|
|
248
|
+
if (pending.endsWith(secret.slice(0, length))) { tail = length; break; }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (final) {
|
|
252
|
+
if (truncated && tail) pending = pending.slice(0, -tail) + "[REDACTED]";
|
|
253
|
+
if (pending) emit(pending);
|
|
254
|
+
pending = "";
|
|
255
|
+
} else {
|
|
256
|
+
const ready = pending.slice(0, pending.length - tail);
|
|
257
|
+
pending = pending.slice(pending.length - tail);
|
|
258
|
+
if (ready) emit(ready);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async function execute(root, request, { restricted = true, signal, secrets = [], onOutput } = {}) {
|
|
263
|
+
if (signal?.aborted) throw new Error("Operation canceled");
|
|
193
264
|
if (request.operation !== "exec") return fileOperation(root, request);
|
|
194
265
|
if (restricted) {
|
|
195
266
|
const result = await builtin(root, request.path ?? "", request.command);
|
|
196
|
-
|
|
267
|
+
if (signal?.aborted) throw new Error("Operation canceled");
|
|
268
|
+
let stdout = "";
|
|
269
|
+
outputRedactor(secrets, text => { stdout += text; onOutput?.("stdout", text); })(result.text, true, result.truncated);
|
|
270
|
+
return { stdout, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
|
|
197
271
|
}
|
|
198
272
|
const { absolute } = await resolveSafe(root, request.path ?? "", "directory");
|
|
199
273
|
if (typeof request.command !== "string" || request.command.length > 4096 || request.command.includes("\0")) throw new Error("Invalid command");
|
|
200
274
|
return new Promise((resolve, reject) => {
|
|
201
275
|
const env = { ...process.env };
|
|
202
276
|
for (const k of Object.keys(env)) if (/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION/iu.test(k)) delete env[k];
|
|
203
|
-
const child = spawn(process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/d", "/s", "/c", request.command] : ["-c", request.command], { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true });
|
|
204
|
-
let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false;
|
|
277
|
+
const child = spawn(process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/d", "/s", "/c", request.command] : ["-c", request.command], { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
278
|
+
let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, streamError;
|
|
205
279
|
const kill = () => {
|
|
206
280
|
if (process.platform === "win32") {
|
|
207
281
|
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
@@ -220,13 +294,19 @@ async function execute(root, request, { restricted = true, signal, secrets = []
|
|
|
220
294
|
const abort = () => kill();
|
|
221
295
|
signal?.addEventListener("abort", abort, { once: true });
|
|
222
296
|
if (signal?.aborted) kill();
|
|
297
|
+
const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
298
|
+
const redact = Object.fromEntries(["stdout", "stderr"].map(which => [which, outputRedactor(secrets, text => {
|
|
299
|
+
if (which === "stdout") stdout += text;
|
|
300
|
+
else stderr += text;
|
|
301
|
+
if (!signal?.aborted && !streamError) {
|
|
302
|
+
try { onOutput?.(which, text); } catch (error) { streamError = error; kill(); }
|
|
303
|
+
}
|
|
304
|
+
})]));
|
|
223
305
|
const append = (which, chunk) => {
|
|
224
306
|
const room = Math.max(0, MAX_OUTPUT - bytes);
|
|
225
|
-
const s = chunk.subarray(0, room).toString("utf8");
|
|
226
307
|
bytes += chunk.length;
|
|
227
308
|
if (bytes > MAX_OUTPUT) truncated = true;
|
|
228
|
-
|
|
229
|
-
else stderr += s;
|
|
309
|
+
redact[which](decoders[which].write(chunk.subarray(0, room)));
|
|
230
310
|
};
|
|
231
311
|
child.stdout.on("data", (c) => append("stdout", c));
|
|
232
312
|
child.stderr.on("data", (c) => append("stderr", c));
|
|
@@ -238,12 +318,13 @@ async function execute(root, request, { restricted = true, signal, secrets = []
|
|
|
238
318
|
child.once("close", (code, sig) => {
|
|
239
319
|
clearTimeout(timer);
|
|
240
320
|
signal?.removeEventListener("abort", abort);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
321
|
+
for (const which of ["stdout", "stderr"]) redact[which](decoders[which].end(), true, truncated);
|
|
322
|
+
if (signal?.aborted) {
|
|
323
|
+
const error = new Error("Operation canceled");
|
|
324
|
+
error.output = { stdout, stderr, exitCode: null, canceled: true, truncated, timedOut };
|
|
325
|
+
return reject(error);
|
|
245
326
|
}
|
|
246
|
-
;
|
|
327
|
+
if (streamError) return reject(streamError);
|
|
247
328
|
resolve({ stdout, stderr, exitCode: code, signal: sig, truncated, timedOut });
|
|
248
329
|
});
|
|
249
330
|
});
|
|
@@ -256,5 +337,7 @@ export {
|
|
|
256
337
|
list,
|
|
257
338
|
resolveSafe,
|
|
258
339
|
rootDescriptor,
|
|
259
|
-
safeRelative
|
|
340
|
+
safeRelative,
|
|
341
|
+
terminalDirectory,
|
|
342
|
+
parseTerminalDirectory
|
|
260
343
|
};
|