@yep-ai/cli 1.0.0 → 1.0.2
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 +36 -29
- package/lib/device/client.js +207 -21
- package/lib/device/command.js +10 -1
- package/lib/device/credential-rotation.js +37 -0
- package/lib/device/native-terminal.js +108 -0
- package/lib/device/tools.js +98 -13
- package/lib/device/windows-execution.js +31 -0
- package/native/darwin-arm64/yep-node +0 -0
- package/native/darwin-x64/yep-node +0 -0
- package/native/linux-arm64/yep-node +0 -0
- package/native/linux-x64/yep-node +0 -0
- package/native/manifest.json +36 -0
- package/native/win32-arm64/yep-node.exe +0 -0
- package/native/win32-x64/yep-node.exe +0 -0
- package/package.json +3 -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
|
|
14
|
+
npx --yes @yep-ai/cli@latest --help
|
|
15
|
+
npx --yes @yep-ai/cli@latest device start
|
|
17
16
|
```
|
|
18
17
|
|
|
19
18
|
本包包含完整实现。`yep-cli` 是计划中的同版本入口包,当前尚未发布本项目版本,请使用 `@yep-ai/cli`。
|
|
@@ -25,32 +24,33 @@ npx --yes @yep-ai/cli device start --server https://yep.nihao.cool
|
|
|
25
24
|
```sh
|
|
26
25
|
npm install
|
|
27
26
|
npm run yep -- --help
|
|
27
|
+
npm run build:yep-node
|
|
28
28
|
npm run pack:cli
|
|
29
|
-
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.
|
|
29
|
+
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.2.tgz ./dist/cli/unscoped/yep-cli-1.0.2.tgz
|
|
30
30
|
./tmp/yep-install/node_modules/.bin/yep --version
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
完整实现包为 `scoped/yep-ai-cli-1.0.
|
|
33
|
+
完整实现包为 `scoped/yep-ai-cli-1.0.2.tgz`,入口包为 `unscoped/yep-cli-1.0.2.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
|
|
34
34
|
|
|
35
35
|
## 市场与 Git 内容版本
|
|
36
36
|
|
|
37
37
|
```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
|
|
38
|
+
npx --yes @yep-ai/cli@latest login --registry https://yep.nihao.cool
|
|
39
|
+
npx --yes @yep-ai/cli@latest whoami
|
|
40
|
+
npx --yes @yep-ai/cli@latest search assistant
|
|
41
|
+
npx --yes @yep-ai/cli@latest info @creator/demo
|
|
42
|
+
npx --yes @yep-ai/cli@latest client set codex,claude
|
|
43
|
+
npx --yes @yep-ai/cli@latest install @creator/demo --client codex
|
|
44
|
+
npx --yes @yep-ai/cli@latest list
|
|
45
|
+
npx --yes @yep-ai/cli@latest list --remote
|
|
46
|
+
npx --yes @yep-ai/cli@latest list --mine
|
|
47
|
+
npx --yes @yep-ai/cli@latest uninstall @creator/demo --client codex
|
|
48
|
+
npx --yes @yep-ai/cli@latest publish ./my-skill
|
|
49
|
+
npx --yes @yep-ai/cli@latest history @creator/demo
|
|
50
|
+
npx --yes @yep-ai/cli@latest diff @creator/demo --base published
|
|
51
|
+
npx --yes @yep-ai/cli@latest publish ./my-agent --kind agent
|
|
52
|
+
npx --yes @yep-ai/cli@latest submit @creator/demo --kind agent
|
|
53
|
+
npx --yes @yep-ai/cli@latest withdraw @creator/demo --kind agent
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
默认资源类型为 `skill`,也支持 `--kind agent|plugin`。Skill 私有保存后从网页申请共享审核;Agent/Plugin 的发布会提交审核。审核通过才进入公开市场。`history` 和 `diff` 访问服务端保留的真实 Git 版本。
|
|
@@ -60,15 +60,22 @@ yep withdraw @creator/demo --kind agent
|
|
|
60
60
|
## 设备节点
|
|
61
61
|
|
|
62
62
|
```sh
|
|
63
|
-
yep device login
|
|
64
|
-
yep device start "/path/to/documents"
|
|
65
|
-
yep device status
|
|
66
|
-
yep device logout
|
|
63
|
+
npx --yes @yep-ai/cli@latest device login
|
|
64
|
+
npx --yes @yep-ai/cli@latest device start "/path/to/documents"
|
|
65
|
+
npx --yes @yep-ai/cli@latest device status
|
|
66
|
+
npx --yes @yep-ai/cli@latest device logout
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5 个目录;指定目录时仅提供受限内置命令。不指定目录时,命令拥有当前系统用户的 shell 权限,可访问其他文件和网络。进程退出后节点离线,不安装后台服务。设备授权最长 24 小时,可以从网页或 CLI 撤销。
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
节点上线后,可在「设备节点」点击「打开终端」。不指定启动目录时,npm 包内的 Go `yep-node` 为每个标签启动独立的真实 Shell:Windows 使用 ConPTY + cmd,macOS / Linux 使用 PTY + 当前用户的 Shell。输入直接进入终端,工作目录、变量和交互程序状态保留;支持 Ctrl+C、窗口尺寸变化和最多 4 个独立网页会话。AI 操作与网页终端分别执行,不共用工作目录或进程。关闭标签、撤销授权、节点退出或网络连接断开会终止对应 Shell;重连创建新会话,不重放命令。
|
|
72
|
+
|
|
73
|
+
原生程序随 npm 包一起分发,无需用户安装 Go。当前构建目标为 Windows x64 / ARM64、macOS Intel / Apple Silicon、Linux x64 / ARM64;Windows 需要支持 ConPTY 的 Windows 10 1809 / Server 2019 或更新版本。鸿蒙 / OpenHarmony 原生 ABI 尚未适配,不能将 Linux ARM64 包视为鸿蒙包。缺少原生程序时会明确提示并提供兼容的逐条命令模式;受限目录始终使用内置命令模式,不启动系统 Shell。
|
|
74
|
+
|
|
75
|
+
设备配对和网络通道使用 HTTPS / WSS;服务端保存令牌摘要,客户端每小时轮换设备令牌并保留短暂交接窗口,轮换不会延长原有 24 小时授权。客户端先持久化待交接凭据,断线后继续完成交接。TLS 在站点入口终止,集群内部连接不属于端到端加密。
|
|
76
|
+
指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes @yep-ai/cli@latest`,保留原目录参数。
|
|
77
|
+
|
|
78
|
+
设备默认连接 `https://yep.nihao.cool`,使用本站时无需指定服务器。连接自建站点时可使用 `--server https://your-site.example`;只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站,令牌按源站隔离。市场登录不等于设备授权。
|
|
72
79
|
|
|
73
80
|
## 配置
|
|
74
81
|
|
|
@@ -86,4 +93,4 @@ yep device logout
|
|
|
86
93
|
|
|
87
94
|
市场凭据保存为 `~/.config/yep/config.json`;设备身份和各源站的授权保存在 `~/.config/yep/devices/`。目录权限 `0700`、凭据文件 `0600`;Windows 使用当前用户专属 ACL。市场和设备令牌分别管理。域名改变后需要在新源站重新登录、配对,不会把旧源站令牌发送到新地址。
|
|
88
95
|
|
|
89
|
-
执行 `yep --help` 或 `yep device --help` 查看全部选项。
|
|
96
|
+
执行 `npx --yes @yep-ai/cli@latest --help` 或 `npx --yes @yep-ai/cli@latest device --help` 查看全部选项。
|
package/lib/device/client.js
CHANGED
|
@@ -4,7 +4,9 @@ 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
|
+
import { findNativeBinary, NativeTerminal } from "./native-terminal.js";
|
|
9
|
+
import { rotateDeviceCredential } from "./credential-rotation.js";
|
|
8
10
|
const PROTOCOL = 2;
|
|
9
11
|
const DEFAULT_SERVER = "https://yep.nihao.cool";
|
|
10
12
|
function serverOrigin(value) {
|
|
@@ -84,9 +86,22 @@ async function loadCredential(directory, server) {
|
|
|
84
86
|
async function saveCredential(directory, server, value) {
|
|
85
87
|
const location = credentialPath(directory, server);
|
|
86
88
|
const temporary = location + "." + randomUUID();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
try {
|
|
90
|
+
const file = await fs.open(temporary, "wx", 0o600);
|
|
91
|
+
try {
|
|
92
|
+
await file.writeFile(JSON.stringify({ ...value, server }));
|
|
93
|
+
await protect(temporary);
|
|
94
|
+
// Rotation must not reach the server before its recovery secret reaches disk.
|
|
95
|
+
await file.sync();
|
|
96
|
+
} finally { await file.close(); }
|
|
97
|
+
await fs.rename(temporary, location);
|
|
98
|
+
if (process.platform !== "win32") {
|
|
99
|
+
const parent = await fs.open(directory, "r");
|
|
100
|
+
try { await parent.sync(); } finally { await parent.close(); }
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
await fs.unlink(temporary).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
104
|
+
}
|
|
90
105
|
}
|
|
91
106
|
async function loadDefaultServer(directory) {
|
|
92
107
|
const location = path.join(directory, "default-server.json");
|
|
@@ -112,7 +127,9 @@ async function api(server, route, { token, body, method = "GET" } = {}) {
|
|
|
112
127
|
try {
|
|
113
128
|
result = await response.json();
|
|
114
129
|
} catch {
|
|
115
|
-
|
|
130
|
+
const error = new Error(response.ok ? "Server returned an invalid response" : `HTTP ${response.status}`);
|
|
131
|
+
if (!response.ok) error.status = response.status;
|
|
132
|
+
throw error;
|
|
116
133
|
}
|
|
117
134
|
;
|
|
118
135
|
if (!response.ok) {
|
|
@@ -156,6 +173,7 @@ ${verify.href}`);
|
|
|
156
173
|
;
|
|
157
174
|
if (response.status === "denied") throw new Error("Device authorization was denied");
|
|
158
175
|
if (response.status === "authorized") {
|
|
176
|
+
response.issuedAt = new Date().toISOString();
|
|
159
177
|
await saveCredential(config.directory, server, response);
|
|
160
178
|
console.log("设备授权已保存(最多24小时)。");
|
|
161
179
|
return response;
|
|
@@ -185,16 +203,18 @@ async function systemRoots(privateDir) {
|
|
|
185
203
|
}));
|
|
186
204
|
}
|
|
187
205
|
class DeviceAgent {
|
|
188
|
-
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt } = {}) {
|
|
206
|
+
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt, onTerminalOutput } = {}) {
|
|
189
207
|
this.roots = roots;
|
|
190
208
|
this.restricted = restricted;
|
|
191
209
|
this.privateDir = privateDir;
|
|
192
210
|
this.token = token;
|
|
193
211
|
this.connectionId = connectionId;
|
|
194
212
|
this.expiresAt = expiresAt;
|
|
213
|
+
this.onTerminalOutput = onTerminalOutput;
|
|
195
214
|
this.closed = false;
|
|
196
215
|
this.bindings = /* @__PURE__ */ new Map();
|
|
197
216
|
this.active = /* @__PURE__ */ new Map();
|
|
217
|
+
this.terminalSessions = new Map();
|
|
198
218
|
}
|
|
199
219
|
publicRoots() {
|
|
200
220
|
return this.roots.map(({ id, name }) => ({ id, name }));
|
|
@@ -202,7 +222,7 @@ class DeviceAgent {
|
|
|
202
222
|
cancel(frame = {}) {
|
|
203
223
|
const waiting = [];
|
|
204
224
|
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) {
|
|
225
|
+
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
226
|
op.abort.abort();
|
|
207
227
|
waiting.push(op.done);
|
|
208
228
|
}
|
|
@@ -213,6 +233,62 @@ class DeviceAgent {
|
|
|
213
233
|
this.closed = true;
|
|
214
234
|
this.cancel();
|
|
215
235
|
this.bindings.clear();
|
|
236
|
+
for (const session of this.terminalSessions.values()) session.closed = true;
|
|
237
|
+
this.terminalSessions.clear();
|
|
238
|
+
}
|
|
239
|
+
async terminalFrame(frame) {
|
|
240
|
+
const base = { id: frame.id, sessionId: frame.sessionId };
|
|
241
|
+
if (typeof frame.sessionId !== "string" || !frame.sessionId || frame.sessionId.length > 200) throw new Error("Invalid terminal session");
|
|
242
|
+
if (["terminal_cancel", "terminal_close"].includes(frame.type)) {
|
|
243
|
+
if (frame.type === "terminal_close") {
|
|
244
|
+
const session = this.terminalSessions.get(frame.sessionId);
|
|
245
|
+
if (session) session.closed = true;
|
|
246
|
+
this.terminalSessions.delete(frame.sessionId);
|
|
247
|
+
}
|
|
248
|
+
await this.cancel({ sessionId: frame.sessionId, ...(frame.type === "terminal_cancel" && frame.id ? { id: frame.id } : {}) });
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
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");
|
|
252
|
+
if (typeof frame.command !== "string" || !frame.command.trim() || frame.command.length > 4096 || frame.command.includes("\0")) throw new Error("Invalid command");
|
|
253
|
+
const root = this.roots.find(item => item.id === frame.folderId);
|
|
254
|
+
if (!root) throw new Error("Unknown terminal root");
|
|
255
|
+
let session = this.terminalSessions.get(frame.sessionId);
|
|
256
|
+
if (!session) {
|
|
257
|
+
if (this.terminalSessions.size >= 8) throw new Error("Too many terminal sessions");
|
|
258
|
+
this.terminalSessions.set(frame.sessionId, session = { closed: false });
|
|
259
|
+
}
|
|
260
|
+
if ([...this.active.values()].some(op => op.sessionId === frame.sessionId)) throw new Error("Terminal command is still running");
|
|
261
|
+
const abort = new AbortController();
|
|
262
|
+
let complete;
|
|
263
|
+
const done = new Promise(resolve => { complete = resolve; });
|
|
264
|
+
this.active.set(frame.id, { abort, sessionId: frame.sessionId, done });
|
|
265
|
+
const valid = () => !this.closed && !session.closed && !abort.signal.aborted;
|
|
266
|
+
try {
|
|
267
|
+
const cwd = frame.path ?? "";
|
|
268
|
+
const changed = await terminalDirectory(root, cwd, frame.command, this.restricted);
|
|
269
|
+
if (!valid()) throw new Error("Operation canceled");
|
|
270
|
+
const output = changed !== null ? { stdout: "", stderr: "", exitCode: 0 } : await execute(root, { operation: "exec", path: cwd, command: frame.command }, {
|
|
271
|
+
restricted: this.restricted, signal: abort.signal, secrets: [this.token],
|
|
272
|
+
onOutput: (stream, data) => {
|
|
273
|
+
if (!valid()) return;
|
|
274
|
+
for (let offset = 0; offset < data.length;) {
|
|
275
|
+
let end = Math.min(data.length, offset + 4096);
|
|
276
|
+
const last = data.charCodeAt(end - 1);
|
|
277
|
+
if (end < data.length && last >= 0xd800 && last <= 0xdbff) end--;
|
|
278
|
+
this.onTerminalOutput?.({ ...base, type: "terminal_output", stream, data: data.slice(offset, end) });
|
|
279
|
+
offset = end;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
if (!valid()) throw new Error("Operation canceled");
|
|
284
|
+
return { ...base, type: "terminal_result", folderId: root.id, path: changed ?? cwd, output };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (abort.signal.aborted) return { ...base, type: "terminal_result", folderId: root.id, path: frame.path ?? "", output: error.output ?? { stdout: "", stderr: "", exitCode: null, canceled: true } };
|
|
287
|
+
throw error;
|
|
288
|
+
} finally {
|
|
289
|
+
this.active.delete(frame.id);
|
|
290
|
+
complete();
|
|
291
|
+
}
|
|
216
292
|
}
|
|
217
293
|
async frame(frame) {
|
|
218
294
|
if (this.closed) throw new Error("Stale device connection");
|
|
@@ -220,6 +296,7 @@ class DeviceAgent {
|
|
|
220
296
|
this.close();
|
|
221
297
|
throw new Error("Device authorization expired");
|
|
222
298
|
}
|
|
299
|
+
if (["terminal_exec", "terminal_cancel", "terminal_close"].includes(frame.type)) return this.terminalFrame(frame);
|
|
223
300
|
const base = { id: frame.id, consultationId: frame.consultationId, bindingId: frame.bindingId, roundId: frame.roundId };
|
|
224
301
|
if (frame.type === "cancel") {
|
|
225
302
|
await this.cancel(frame);
|
|
@@ -294,63 +371,158 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
294
371
|
if (directories.length > 5) throw new Error("At most five startup directories");
|
|
295
372
|
const roots = restricted ? await Promise.all(directories.map((p, i) => rootDescriptor("root-" + (i + 1), p, config.directory))) : await systemRoots(config.directory);
|
|
296
373
|
if (!roots.length) throw new Error("No accessible filesystem roots");
|
|
374
|
+
const nativeBinary = restricted ? null : await findNativeBinary();
|
|
375
|
+
if (!restricted && !nativeBinary) console.warn("未找到当前平台的原生终端组件,使用兼容命令模式。");
|
|
297
376
|
console.log(restricted ? "受限目录模式:仅内置命令,不能启动 shell 或外部程序。" : "完整系统模式:网页授权执行的命令拥有当前系统用户权限,可访问其他文件和网络;这不是沙箱。");
|
|
298
377
|
let delay = 1e3;
|
|
378
|
+
const rotationInterval = 60 * 60 * 1000;
|
|
379
|
+
// Older credentials lack issuedAt. Their fixed 24-hour lifetime still gives
|
|
380
|
+
// an issuance bound, so reconnecting/restarting cannot postpone rotation.
|
|
381
|
+
const rotationDeadline = value => {
|
|
382
|
+
const base = Date.parse(value.rotatedAt || value.issuedAt);
|
|
383
|
+
const issued = Date.parse(value.expiresAt) - 24 * rotationInterval;
|
|
384
|
+
return (Number.isFinite(base) ? Math.min(base, Date.now()) : Math.min(issued, Date.now())) + rotationInterval;
|
|
385
|
+
};
|
|
386
|
+
let nextRotation = rotationDeadline(credential);
|
|
387
|
+
const retryDelay = () => new Promise(resolve => {
|
|
388
|
+
const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); };
|
|
389
|
+
const timer = setTimeout(finish, delay);
|
|
390
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
391
|
+
if (signal?.aborted) finish();
|
|
392
|
+
});
|
|
393
|
+
const recoverCredential = async () => {
|
|
394
|
+
const saved = credential.deviceId ? await loadCredential(config.directory, server) : null;
|
|
395
|
+
if (!saved) return;
|
|
396
|
+
if (saved.deviceId !== credential.deviceId) {
|
|
397
|
+
const error = new Error("Device credential changed");
|
|
398
|
+
error.code = "YEP_CREDENTIAL_CHANGED";
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
credential = saved.pendingToken ? await rotateDeviceCredential(server, saved, { api, save: value => saveCredential(config.directory, server, value) }) : saved;
|
|
402
|
+
nextRotation = Math.min(nextRotation, rotationDeadline(credential));
|
|
403
|
+
if (!credential.pendingToken && saved.pendingToken) nextRotation = rotationDeadline(credential);
|
|
404
|
+
};
|
|
299
405
|
while (!signal?.aborted) {
|
|
406
|
+
try {
|
|
407
|
+
// A previous rotate response or final save may have been lost. Recover
|
|
408
|
+
// from disk before presenting a bearer on a new WebSocket connection.
|
|
409
|
+
await recoverCredential();
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (error.code === "YEP_CREDENTIAL_CHANGED") throw error;
|
|
412
|
+
if ([401, 403].includes(error.status)) throw new Error("Device authorization expired or revoked");
|
|
413
|
+
console.log(`设备凭据同步暂时失败;${Math.ceil(delay / 1e3)}秒后重试。`);
|
|
414
|
+
await retryDelay();
|
|
415
|
+
delay = Math.min(delay * 2, 3e4);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
300
418
|
if (Date.now() >= Date.parse(credential.expiresAt)) throw new Error("Device authorization expired; run login again");
|
|
301
419
|
let fatal;
|
|
420
|
+
let recoverableAuthorization = false;
|
|
302
421
|
await new Promise((resolve, reject) => {
|
|
303
422
|
const url = new URL("/api/local-cli/connect", server);
|
|
304
423
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
305
424
|
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
|
|
425
|
+
const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt,
|
|
426
|
+
onTerminalOutput: frame => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame)); },
|
|
427
|
+
});
|
|
428
|
+
const native = nativeBinary ? new NativeTerminal(nativeBinary, frame => {
|
|
429
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
430
|
+
if (ws.bufferedAmount > 2 * 1024 * 1024) { ws.terminate(); return; }
|
|
431
|
+
ws.send(JSON.stringify(frame));
|
|
432
|
+
}) : null;
|
|
307
433
|
let ready = false;
|
|
308
434
|
let heartbeat;
|
|
435
|
+
let receiveWatchdog;
|
|
436
|
+
let credentialRotation = false;
|
|
437
|
+
let rotationTask = null;
|
|
438
|
+
let awaitingCredential = false;
|
|
439
|
+
const received = () => {
|
|
440
|
+
if (ws.readyState !== WebSocket.OPEN || fatal || signal?.aborted) return;
|
|
441
|
+
clearTimeout(receiveWatchdog);
|
|
442
|
+
receiveWatchdog = setTimeout(() => {
|
|
443
|
+
ready = false;
|
|
444
|
+
native?.close();
|
|
445
|
+
agent.close();
|
|
446
|
+
ws.terminate();
|
|
447
|
+
}, 45000);
|
|
448
|
+
};
|
|
449
|
+
const updateCredential = () => {
|
|
450
|
+
if (awaitingCredential && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "credential_update", token: credential.token }));
|
|
451
|
+
};
|
|
452
|
+
const rotate = () => {
|
|
453
|
+
if (!credentialRotation || rotationTask || awaitingCredential || !ready || signal?.aborted) return;
|
|
454
|
+
rotationTask = (async () => {
|
|
455
|
+
const saved = await loadCredential(config.directory, server);
|
|
456
|
+
if (!saved || saved.deviceId !== credential.deviceId) throw new Error("Device credential changed");
|
|
457
|
+
const next = await rotateDeviceCredential(server, saved, { api, save: value => saveCredential(config.directory, server, value) });
|
|
458
|
+
credential = next;
|
|
459
|
+
agent.token = next.token;
|
|
460
|
+
nextRotation = Date.now() + rotationInterval;
|
|
461
|
+
if (ws.readyState === WebSocket.OPEN && ready) { awaitingCredential = true; updateCredential(); }
|
|
462
|
+
})().catch(() => { nextRotation = Date.now() + 60 * 1000; }).finally(() => { rotationTask = null; });
|
|
463
|
+
};
|
|
309
464
|
const cleanup = () => {
|
|
310
465
|
clearInterval(heartbeat);
|
|
466
|
+
clearTimeout(receiveWatchdog);
|
|
467
|
+
native?.close();
|
|
311
468
|
agent.close();
|
|
312
469
|
signal?.removeEventListener("abort", abort);
|
|
313
470
|
};
|
|
314
471
|
const abort = () => {
|
|
315
472
|
ready = false;
|
|
473
|
+
native?.close();
|
|
316
474
|
agent.close();
|
|
317
475
|
ws.close();
|
|
318
476
|
};
|
|
319
477
|
signal?.addEventListener("abort", abort, { once: true });
|
|
320
478
|
ws.on("open", () => {
|
|
321
|
-
|
|
479
|
+
received();
|
|
480
|
+
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", ...(native ? ["pty"] : [])] }));
|
|
322
481
|
});
|
|
482
|
+
ws.on("ping", received);
|
|
483
|
+
ws.on("pong", received);
|
|
323
484
|
ws.on("unexpected-response", (_req, res) => {
|
|
324
485
|
if ([401, 403].includes(res.statusCode)) {
|
|
325
486
|
fatal = new Error("Device authorization expired or revoked");
|
|
326
|
-
|
|
487
|
+
recoverableAuthorization = true;
|
|
327
488
|
}
|
|
489
|
+
// With this listener installed ws leaves response ownership to us.
|
|
490
|
+
// Explicitly end every rejected handshake, including transient 503s.
|
|
491
|
+
res.destroy();
|
|
492
|
+
ws.terminate();
|
|
328
493
|
});
|
|
329
494
|
ws.on("message", async (bytes) => {
|
|
330
495
|
if (fatal || signal?.aborted) return;
|
|
496
|
+
received();
|
|
331
497
|
let frame;
|
|
332
498
|
try {
|
|
333
499
|
frame = JSON.parse(bytes.toString());
|
|
334
500
|
if (frame.type === "ready") {
|
|
335
501
|
if (frame.protocol !== PROTOCOL) throw new Error("Unsupported server protocol");
|
|
336
502
|
agent.connectionId = frame.connectionId;
|
|
503
|
+
credentialRotation = frame.credentialRotation === true;
|
|
337
504
|
ready = true;
|
|
338
505
|
delay = 1e3;
|
|
339
|
-
console.log("
|
|
506
|
+
console.log("设备已上线。可在网页设备节点中打开终端,或在咨询中选择目录;退出此进程将断开。");
|
|
340
507
|
heartbeat = setInterval(() => {
|
|
341
508
|
if (Date.now() >= Date.parse(credential.expiresAt)) {
|
|
342
509
|
fatal = new Error("Device authorization expired");
|
|
510
|
+
native?.close();
|
|
343
511
|
agent.close();
|
|
344
512
|
ws.close();
|
|
345
513
|
return;
|
|
346
514
|
}
|
|
347
515
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
|
516
|
+
updateCredential();
|
|
517
|
+
if (Date.now() >= nextRotation) rotate();
|
|
348
518
|
}, 1e4);
|
|
349
519
|
return;
|
|
350
520
|
}
|
|
351
521
|
if (["revoked", "expired", "replaced"].includes(frame.type)) {
|
|
352
522
|
fatal = new Error(`Device ${frame.type}; local operations stopped`);
|
|
523
|
+
recoverableAuthorization = frame.type === "revoked";
|
|
353
524
|
ready = false;
|
|
525
|
+
native?.close();
|
|
354
526
|
agent.close();
|
|
355
527
|
ws.close();
|
|
356
528
|
return;
|
|
@@ -359,7 +531,18 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
359
531
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
360
532
|
return;
|
|
361
533
|
}
|
|
534
|
+
if (frame.type === "credential_updated") { awaitingCredential = false; return; }
|
|
535
|
+
if (frame.type === "credential_update_error") { awaitingCredential = true; return; }
|
|
362
536
|
if (!ready) return;
|
|
537
|
+
if (["pty_open", "pty_input", "pty_resize", "pty_close"].includes(frame.type)) {
|
|
538
|
+
try {
|
|
539
|
+
if (!native) throw new Error("Interactive terminal unavailable");
|
|
540
|
+
native.write(frame);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "pty_error", sessionId: frame.sessionId, error: String(error.message).slice(0, 1024) }));
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
363
546
|
const result = await agent.frame(frame);
|
|
364
547
|
if (result && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(result));
|
|
365
548
|
} catch (e) {
|
|
@@ -367,30 +550,33 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
367
550
|
if (frame?.id && ws.readyState === WebSocket.OPEN) {
|
|
368
551
|
let error = String(e.message).split(credential.token).join("[REDACTED]").split(config.directory).join("[私有目录]");
|
|
369
552
|
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) }));
|
|
553
|
+
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
554
|
} else {
|
|
372
555
|
fatal = e;
|
|
556
|
+
native?.close();
|
|
557
|
+
agent.close();
|
|
373
558
|
ws.close();
|
|
374
559
|
}
|
|
375
560
|
}
|
|
376
561
|
});
|
|
377
562
|
ws.on("error", () => {
|
|
378
563
|
});
|
|
379
|
-
ws.on("close", () => {
|
|
564
|
+
ws.on("close", async () => {
|
|
380
565
|
cleanup();
|
|
566
|
+
if (rotationTask) await rotationTask;
|
|
381
567
|
resolve();
|
|
382
568
|
});
|
|
383
569
|
});
|
|
384
|
-
if (fatal)
|
|
570
|
+
if (fatal) {
|
|
571
|
+
if (!recoverableAuthorization) throw fatal;
|
|
572
|
+
const saved = credential.deviceId ? await loadCredential(config.directory, server) : null;
|
|
573
|
+
if (!saved?.pendingToken) throw fatal;
|
|
574
|
+
// Local operations have already stopped. A pending secret can distinguish
|
|
575
|
+
// an expired overlap from an actual revocation on the next recovery pass.
|
|
576
|
+
}
|
|
385
577
|
if (signal?.aborted) break;
|
|
386
578
|
console.log(`连接已断开;${Math.ceil(delay / 1e3)}秒后重连。原咨询绑定已失效,重连后需重新选择。`);
|
|
387
|
-
await
|
|
388
|
-
const t = setTimeout(resolve, delay);
|
|
389
|
-
signal?.addEventListener("abort", () => {
|
|
390
|
-
clearTimeout(t);
|
|
391
|
-
resolve();
|
|
392
|
-
}, { once: true });
|
|
393
|
-
});
|
|
579
|
+
await retryDelay();
|
|
394
580
|
delay = Math.min(delay * 2, 3e4);
|
|
395
581
|
}
|
|
396
582
|
}
|
package/lib/device/command.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
5
|
-
import { serverOrigin, initializeConfig, loadCredential, loadDefaultServer, rememberServer, credentialPath, login, start, api } from "./client.js";
|
|
5
|
+
import { serverOrigin, initializeConfig, loadCredential, loadDefaultServer, rememberServer, credentialPath, login, start, api, saveCredential } from "./client.js";
|
|
6
|
+
import { rotateDeviceCredential } from "./credential-rotation.js";
|
|
6
7
|
const HELP = `Yep 本地设备节点 ${version} (协议2,Node.js 22+)
|
|
7
8
|
|
|
8
9
|
yep device start [目录1 ... 目录5] [--server https://your-site.example]
|
|
@@ -45,6 +46,14 @@ export async function runDevice(args = process.argv.slice(2)) {
|
|
|
45
46
|
if (server) await rememberServer(config.directory, server);
|
|
46
47
|
else server = await loadDefaultServer(config.directory);
|
|
47
48
|
let credential = await loadCredential(config.directory, server);
|
|
49
|
+
if (credential?.pendingToken) {
|
|
50
|
+
try {
|
|
51
|
+
credential = await rotateDeviceCredential(server, credential, { api, save: value => saveCredential(config.directory, server, value) });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (command === "start" && (error.status === 401 || error.status === 403)) credential = null;
|
|
54
|
+
else throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
48
57
|
const controller = new AbortController();
|
|
49
58
|
const stop = () => controller.abort();
|
|
50
59
|
process.once("SIGINT", stop);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Persist the next bearer first: either secret can then recover an interrupted rotation.
|
|
4
|
+
export async function rotateDeviceCredential(server, credential, { api, save, createToken = () => randomBytes(32).toString("hex") }) {
|
|
5
|
+
let pending = credential.pendingToken;
|
|
6
|
+
if (!pending) {
|
|
7
|
+
pending = createToken();
|
|
8
|
+
if (!/^[0-9a-f]{64}$/.test(pending) || pending === credential.token) throw new Error("Invalid generated device credential");
|
|
9
|
+
credential = { ...credential, pendingToken: pending };
|
|
10
|
+
await save(credential);
|
|
11
|
+
}
|
|
12
|
+
const tokenHash = createHash("sha256").update(pending).digest("hex");
|
|
13
|
+
try {
|
|
14
|
+
await api(server, "/api/local-cli/rotate", { method: "POST", token: credential.token, body: { tokenHash } });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
// The response may have been lost or the old overlap may have elapsed.
|
|
17
|
+
if (![401, 403, 404, 409].includes(error.status)) throw error;
|
|
18
|
+
let me;
|
|
19
|
+
try {
|
|
20
|
+
me = await api(server, "/api/local-cli/me", { token: pending });
|
|
21
|
+
} catch (pendingError) {
|
|
22
|
+
if (error.status !== 404 || ![401, 403].includes(pendingError.status)) throw pendingError;
|
|
23
|
+
// An older server may not implement rotation. Only discard an unaccepted
|
|
24
|
+
// pending secret after proving that the original credential still works.
|
|
25
|
+
const current = await api(server, "/api/local-cli/me", { token: credential.token });
|
|
26
|
+
if (current.deviceId !== credential.deviceId) throw new Error("Current credential belongs to another device");
|
|
27
|
+
const { pendingToken: _pending, ...unchanged } = credential;
|
|
28
|
+
await save(unchanged);
|
|
29
|
+
return unchanged;
|
|
30
|
+
}
|
|
31
|
+
if (me.deviceId !== credential.deviceId) throw new Error("Rotated credential belongs to another device");
|
|
32
|
+
}
|
|
33
|
+
const { pendingToken: _pending, ...rest } = credential;
|
|
34
|
+
const rotated = { ...rest, token: pending, rotatedAt: new Date().toISOString() };
|
|
35
|
+
await save(rotated);
|
|
36
|
+
return rotated;
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const platforms = new Set(["win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"]);
|
|
9
|
+
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
10
|
+
|
|
11
|
+
export function nativeBinaryPath(platform = process.platform, arch = process.arch) {
|
|
12
|
+
const target = `${platform}-${arch}`;
|
|
13
|
+
if (!platforms.has(target)) return null;
|
|
14
|
+
return path.join(packageRoot, "native", target, platform === "win32" ? "yep-node.exe" : "yep-node");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function findNativeBinary(platform = process.platform, arch = process.arch) {
|
|
18
|
+
const binary = nativeBinaryPath(platform, arch);
|
|
19
|
+
if (!binary) return null;
|
|
20
|
+
try { await access(binary, platform === "win32" ? constants.F_OK : constants.X_OK); return binary; }
|
|
21
|
+
catch (error) { if (error.code === "ENOENT" || error.code === "EACCES") return null; throw error; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class NativeTerminal {
|
|
25
|
+
constructor(binary, send) {
|
|
26
|
+
this.binary = binary;
|
|
27
|
+
this.send = send;
|
|
28
|
+
this.sessions = new Set();
|
|
29
|
+
this.opening = new Set();
|
|
30
|
+
this.closed = false;
|
|
31
|
+
this.failed = false;
|
|
32
|
+
this.startWorker();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
startWorker() {
|
|
36
|
+
const child = spawn(this.binary, [], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
37
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
38
|
+
const worker = { child, lines };
|
|
39
|
+
this.worker = worker;
|
|
40
|
+
this.child = child;
|
|
41
|
+
this.lines = lines;
|
|
42
|
+
this.failed = false;
|
|
43
|
+
lines.on("line", line => {
|
|
44
|
+
if (this.closed || this.failed || this.worker !== worker) return;
|
|
45
|
+
if (line.length > 1024 * 1024) { failed(); return; }
|
|
46
|
+
let frame;
|
|
47
|
+
try { frame = JSON.parse(line); } catch { failed(); return; }
|
|
48
|
+
if (!frame || typeof frame.sessionId !== "string" || !this.sessions.has(frame.sessionId) || !["pty_ready", "pty_output", "pty_exit", "pty_error"].includes(frame.type)) return;
|
|
49
|
+
if (frame.type === "pty_ready") this.opening.delete(frame.sessionId);
|
|
50
|
+
if (frame.type === "pty_exit" || frame.type === "pty_error" && this.opening.has(frame.sessionId)) {
|
|
51
|
+
this.sessions.delete(frame.sessionId);
|
|
52
|
+
this.opening.delete(frame.sessionId);
|
|
53
|
+
}
|
|
54
|
+
this.send(frame);
|
|
55
|
+
});
|
|
56
|
+
const failed = () => {
|
|
57
|
+
if (this.closed || this.failed || this.worker !== worker) return;
|
|
58
|
+
this.failed = true;
|
|
59
|
+
const sessionIds = [...this.sessions];
|
|
60
|
+
this.sessions.clear();
|
|
61
|
+
this.opening.clear();
|
|
62
|
+
this.stopWorker(worker);
|
|
63
|
+
for (const sessionId of sessionIds) this.send({ type: "pty_error", sessionId, error: "Local terminal process stopped; open a new terminal to start a new shell" });
|
|
64
|
+
};
|
|
65
|
+
child.on("error", failed);
|
|
66
|
+
child.on("exit", failed);
|
|
67
|
+
child.stdin.on("error", failed);
|
|
68
|
+
child.stdout.on("error", failed);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stopWorker(worker) {
|
|
72
|
+
if (worker.stopping) return;
|
|
73
|
+
worker.stopping = true;
|
|
74
|
+
worker.lines.close();
|
|
75
|
+
// Keep draining output while the worker closes its PTYs and reaps shells.
|
|
76
|
+
worker.child.stdout.resume();
|
|
77
|
+
worker.child.stdin.end();
|
|
78
|
+
if (worker.child.exitCode !== null || worker.child.signalCode !== null) return;
|
|
79
|
+
const timeout = setTimeout(() => worker.child.kill("SIGKILL"), 2000);
|
|
80
|
+
timeout.unref();
|
|
81
|
+
worker.child.once("exit", () => clearTimeout(timeout));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
write(frame) {
|
|
85
|
+
if (this.closed) throw new Error("Local terminal process is unavailable");
|
|
86
|
+
if (typeof frame.sessionId !== "string" || !frame.sessionId || frame.sessionId.length > 200) throw new Error("Invalid terminal session");
|
|
87
|
+
if (!['pty_open', 'pty_input', 'pty_resize', 'pty_close'].includes(frame.type)) throw new Error("Invalid terminal operation");
|
|
88
|
+
if (this.failed) {
|
|
89
|
+
if (frame.type !== "pty_open") throw new Error("Local terminal process stopped; open a new terminal to start a new shell");
|
|
90
|
+
this.startWorker();
|
|
91
|
+
}
|
|
92
|
+
if (this.child.stdin.writableLength > 1024 * 1024) throw new Error("Local terminal input is busy");
|
|
93
|
+
if (frame.type === "pty_open") {
|
|
94
|
+
if (this.sessions.has(frame.sessionId) || this.sessions.size >= 8) throw new Error("Too many or duplicate terminal sessions");
|
|
95
|
+
this.sessions.add(frame.sessionId);
|
|
96
|
+
this.opening.add(frame.sessionId);
|
|
97
|
+
} else if (!this.sessions.has(frame.sessionId)) throw new Error("Unknown terminal session");
|
|
98
|
+
this.child.stdin.write(JSON.stringify(frame) + "\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
close() {
|
|
102
|
+
if (this.closed) return;
|
|
103
|
+
this.closed = true;
|
|
104
|
+
this.sessions.clear();
|
|
105
|
+
this.opening.clear();
|
|
106
|
+
this.stopWorker(this.worker);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/device/tools.js
CHANGED
|
@@ -2,6 +2,8 @@ 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";
|
|
6
|
+
import { windowsExecution } from "./windows-execution.js";
|
|
5
7
|
const MAX_FILE = 10 * 1024 * 1024;
|
|
6
8
|
const MAX_OUTPUT = 64 * 1024;
|
|
7
9
|
function safeRelative(value = "") {
|
|
@@ -189,19 +191,93 @@ async function builtin(root, cwd, command) {
|
|
|
189
191
|
if (cmd === "wc" && args.length === 2 && args[0] === "-l") return { text: String((await read(args[1])).split("\n").length - 1) + "\n" };
|
|
190
192
|
throw new Error("Unsupported restricted command");
|
|
191
193
|
}
|
|
192
|
-
|
|
194
|
+
function parseTerminalDirectory(command, windows = process.platform === "win32") {
|
|
195
|
+
if (!/^\s*cd(?:\s|$)/iu.test(command)) return null;
|
|
196
|
+
if (/[\0\r\n]/u.test(command)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
197
|
+
let target = command.trim().slice(2).trim();
|
|
198
|
+
if (windows && /^\/d(?:\s|$)/iu.test(target)) target = target.slice(2).trim();
|
|
199
|
+
if (!target) return "";
|
|
200
|
+
const quote = target[0];
|
|
201
|
+
if (quote === '"' || quote === "'") {
|
|
202
|
+
// A directory name is interpreted literally, never as shell source.
|
|
203
|
+
// Windows backslashes must survive quotation rather than become escapes.
|
|
204
|
+
if (target.at(-1) !== quote || target.slice(1, -1).includes(quote)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
205
|
+
return target.slice(1, -1);
|
|
206
|
+
}
|
|
207
|
+
if (/[\s|&;<>$`%!*?()[\]{}"']/u.test(target)) throw new Error("Usage: cd [directory]; run other commands separately");
|
|
208
|
+
return target;
|
|
209
|
+
}
|
|
210
|
+
async function terminalDirectory(root, cwd, command, restricted) {
|
|
211
|
+
let target;
|
|
212
|
+
try {
|
|
213
|
+
target = parseTerminalDirectory(command);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Only a standalone literal cd changes the simulated terminal directory.
|
|
216
|
+
// In system mode, compound commands and shell expressions run normally in
|
|
217
|
+
// their own shell, whose working directory lasts for that command only.
|
|
218
|
+
if (!restricted) return null;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
if (target === null) return null;
|
|
222
|
+
await resolveSafe(root, cwd, "directory");
|
|
223
|
+
if (!restricted && (target === "~" || target.startsWith("~/"))) {
|
|
224
|
+
target = path.join(process.env.USERPROFILE || process.env.HOME || root.absolute, target.slice(2));
|
|
225
|
+
}
|
|
226
|
+
let relative;
|
|
227
|
+
if (path.isAbsolute(target)) {
|
|
228
|
+
if (restricted) throw new Error("Use a relative directory inside the authorized root");
|
|
229
|
+
relative = path.relative(root.absolute, path.resolve(target)).split(path.sep).join("/");
|
|
230
|
+
} else {
|
|
231
|
+
if (process.platform === "win32") target = target.replaceAll("\\", "/");
|
|
232
|
+
relative = target ? path.posix.normalize([cwd, target].filter(Boolean).join("/")) : "";
|
|
233
|
+
}
|
|
234
|
+
if (relative === ".") relative = "";
|
|
235
|
+
await resolveSafe(root, relative, "directory");
|
|
236
|
+
return relative;
|
|
237
|
+
}
|
|
238
|
+
// Retain an unfinished secret prefix across chunks so streaming cannot expose a
|
|
239
|
+
// token that happened to cross a stdout/stderr packet boundary.
|
|
240
|
+
function outputRedactor(secrets, emit) {
|
|
241
|
+
const values = [...new Set(secrets.filter(value => typeof value === "string" && value))].sort((a, b) => b.length - a.length);
|
|
242
|
+
let pending = "";
|
|
243
|
+
return (text, final = false, truncated = false) => {
|
|
244
|
+
pending += text;
|
|
245
|
+
for (const secret of values) pending = pending.split(secret).join("[REDACTED]");
|
|
246
|
+
let tail = 0;
|
|
247
|
+
for (const secret of values) {
|
|
248
|
+
for (let length = Math.min(secret.length - 1, pending.length); length > tail; length--) {
|
|
249
|
+
if (pending.endsWith(secret.slice(0, length))) { tail = length; break; }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (final) {
|
|
253
|
+
if (truncated && tail) pending = pending.slice(0, -tail) + "[REDACTED]";
|
|
254
|
+
if (pending) emit(pending);
|
|
255
|
+
pending = "";
|
|
256
|
+
} else {
|
|
257
|
+
const ready = pending.slice(0, pending.length - tail);
|
|
258
|
+
pending = pending.slice(pending.length - tail);
|
|
259
|
+
if (ready) emit(ready);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async function execute(root, request, { restricted = true, signal, secrets = [], onOutput } = {}) {
|
|
264
|
+
if (signal?.aborted) throw new Error("Operation canceled");
|
|
193
265
|
if (request.operation !== "exec") return fileOperation(root, request);
|
|
194
266
|
if (restricted) {
|
|
195
267
|
const result = await builtin(root, request.path ?? "", request.command);
|
|
196
|
-
|
|
268
|
+
if (signal?.aborted) throw new Error("Operation canceled");
|
|
269
|
+
let stdout = "";
|
|
270
|
+
outputRedactor(secrets, text => { stdout += text; onOutput?.("stdout", text); })(result.text, true, result.truncated);
|
|
271
|
+
return { stdout, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
|
|
197
272
|
}
|
|
198
273
|
const { absolute } = await resolveSafe(root, request.path ?? "", "directory");
|
|
199
274
|
if (typeof request.command !== "string" || request.command.length > 4096 || request.command.includes("\0")) throw new Error("Invalid command");
|
|
200
275
|
return new Promise((resolve, reject) => {
|
|
201
276
|
const env = { ...process.env };
|
|
202
277
|
for (const k of Object.keys(env)) if (/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION/iu.test(k)) delete env[k];
|
|
203
|
-
const
|
|
204
|
-
|
|
278
|
+
const shell = process.platform === "win32" ? windowsExecution(request.command, env) : { executable: "/bin/sh", args: ["-c", request.command] };
|
|
279
|
+
const child = spawn(shell.executable, shell.args, { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
280
|
+
let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, streamError;
|
|
205
281
|
const kill = () => {
|
|
206
282
|
if (process.platform === "win32") {
|
|
207
283
|
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
@@ -220,13 +296,19 @@ async function execute(root, request, { restricted = true, signal, secrets = []
|
|
|
220
296
|
const abort = () => kill();
|
|
221
297
|
signal?.addEventListener("abort", abort, { once: true });
|
|
222
298
|
if (signal?.aborted) kill();
|
|
299
|
+
const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
300
|
+
const redact = Object.fromEntries(["stdout", "stderr"].map(which => [which, outputRedactor(secrets, text => {
|
|
301
|
+
if (which === "stdout") stdout += text;
|
|
302
|
+
else stderr += text;
|
|
303
|
+
if (!signal?.aborted && !streamError) {
|
|
304
|
+
try { onOutput?.(which, text); } catch (error) { streamError = error; kill(); }
|
|
305
|
+
}
|
|
306
|
+
})]));
|
|
223
307
|
const append = (which, chunk) => {
|
|
224
308
|
const room = Math.max(0, MAX_OUTPUT - bytes);
|
|
225
|
-
const s = chunk.subarray(0, room).toString("utf8");
|
|
226
309
|
bytes += chunk.length;
|
|
227
310
|
if (bytes > MAX_OUTPUT) truncated = true;
|
|
228
|
-
|
|
229
|
-
else stderr += s;
|
|
311
|
+
redact[which](decoders[which].write(chunk.subarray(0, room)));
|
|
230
312
|
};
|
|
231
313
|
child.stdout.on("data", (c) => append("stdout", c));
|
|
232
314
|
child.stderr.on("data", (c) => append("stderr", c));
|
|
@@ -238,12 +320,13 @@ async function execute(root, request, { restricted = true, signal, secrets = []
|
|
|
238
320
|
child.once("close", (code, sig) => {
|
|
239
321
|
clearTimeout(timer);
|
|
240
322
|
signal?.removeEventListener("abort", abort);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
323
|
+
for (const which of ["stdout", "stderr"]) redact[which](decoders[which].end(), true, truncated);
|
|
324
|
+
if (signal?.aborted) {
|
|
325
|
+
const error = new Error("Operation canceled");
|
|
326
|
+
error.output = { stdout, stderr, exitCode: null, canceled: true, truncated, timedOut };
|
|
327
|
+
return reject(error);
|
|
245
328
|
}
|
|
246
|
-
;
|
|
329
|
+
if (streamError) return reject(streamError);
|
|
247
330
|
resolve({ stdout, stderr, exitCode: code, signal: sig, truncated, timedOut });
|
|
248
331
|
});
|
|
249
332
|
});
|
|
@@ -256,5 +339,7 @@ export {
|
|
|
256
339
|
list,
|
|
257
340
|
resolveSafe,
|
|
258
341
|
rootDescriptor,
|
|
259
|
-
safeRelative
|
|
342
|
+
safeRelative,
|
|
343
|
+
terminalDirectory,
|
|
344
|
+
parseTerminalDirectory
|
|
260
345
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// PowerShell prepares the console encoding, but cmd still interprets the user's
|
|
4
|
+
// command. Pass cmd's command line directly rather than through PowerShell's
|
|
5
|
+
// native-argument binder, which can rewrite nested quotes on Windows PowerShell.
|
|
6
|
+
export function windowsExecution(command, env = process.env) {
|
|
7
|
+
const executable = env.SystemRoot
|
|
8
|
+
? path.win32.join(env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
9
|
+
: "powershell.exe";
|
|
10
|
+
const shell = env.ComSpec || env.COMSPEC || "cmd.exe";
|
|
11
|
+
const script = [
|
|
12
|
+
"$ErrorActionPreference = 'Stop'",
|
|
13
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
14
|
+
"$utf8 = New-Object System.Text.UTF8Encoding($false)",
|
|
15
|
+
"[Console]::OutputEncoding = $utf8",
|
|
16
|
+
"$OutputEncoding = $utf8",
|
|
17
|
+
"$start = New-Object System.Diagnostics.ProcessStartInfo",
|
|
18
|
+
`$start.FileName = '${shell.replaceAll("'", "''")}'`,
|
|
19
|
+
`$command = '${command.replaceAll("'", "''")}'`,
|
|
20
|
+
"$start.Arguments = '/d /s /c \"' + $command + '\"'",
|
|
21
|
+
"$start.UseShellExecute = $false",
|
|
22
|
+
"$process = New-Object System.Diagnostics.Process",
|
|
23
|
+
"$process.StartInfo = $start",
|
|
24
|
+
"try {",
|
|
25
|
+
" if (-not $process.Start()) { throw 'Unable to start command shell' }",
|
|
26
|
+
" $process.WaitForExit()",
|
|
27
|
+
" exit $process.ExitCode",
|
|
28
|
+
"} finally { $process.Dispose() }",
|
|
29
|
+
].join("\r\n");
|
|
30
|
+
return { executable, args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-InputFormat", "Text", "-OutputFormat", "Text", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")] };
|
|
31
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"sourceSHA256": "800213014e6af32ed8b98749e832752b940ae638eaefa472aceb7b62e815b3e4",
|
|
4
|
+
"files": {
|
|
5
|
+
"win32-x64/yep-node.exe": {
|
|
6
|
+
"sha256": "b3b4ae3515f9408e2535af8d1a926b92099892968b1493fd781092a1299afdfd",
|
|
7
|
+
"goos": "windows",
|
|
8
|
+
"goarch": "amd64"
|
|
9
|
+
},
|
|
10
|
+
"win32-arm64/yep-node.exe": {
|
|
11
|
+
"sha256": "73e46d8bfa35c599411c3c02adef068cd9e0e7ba317b4fa309d778381d214aac",
|
|
12
|
+
"goos": "windows",
|
|
13
|
+
"goarch": "arm64"
|
|
14
|
+
},
|
|
15
|
+
"darwin-x64/yep-node": {
|
|
16
|
+
"sha256": "95bead46821a2800958d8dc865d67d9bfe83ac666b4796d046f2586b020989fc",
|
|
17
|
+
"goos": "darwin",
|
|
18
|
+
"goarch": "amd64"
|
|
19
|
+
},
|
|
20
|
+
"darwin-arm64/yep-node": {
|
|
21
|
+
"sha256": "6368ef2786d34168d6b2051d722fca2c4eaa7d261bfef29309dd10ef558718b7",
|
|
22
|
+
"goos": "darwin",
|
|
23
|
+
"goarch": "arm64"
|
|
24
|
+
},
|
|
25
|
+
"linux-x64/yep-node": {
|
|
26
|
+
"sha256": "e2d0cf28712d255b6aa7e4e010203abf6c3ea5f971b5a8db78a63ebd37314fad",
|
|
27
|
+
"goos": "linux",
|
|
28
|
+
"goarch": "amd64"
|
|
29
|
+
},
|
|
30
|
+
"linux-arm64/yep-node": {
|
|
31
|
+
"sha256": "c956a49fc196d2cbf5779fef63e1a1f21d86eb6cc67d4abbf583f954f3422d43",
|
|
32
|
+
"goos": "linux",
|
|
33
|
+
"goarch": "arm64"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yep-ai/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Yep Marketplace unified CLI: skills, agents, plugins and authorized device nodes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"bin/market.js",
|
|
12
12
|
"bin/clients.js",
|
|
13
13
|
"lib",
|
|
14
|
+
"native",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
16
17
|
"engines": {
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"registry": "https://registry.npmjs.org"
|
|
26
27
|
},
|
|
27
28
|
"scripts": {
|
|
29
|
+
"prepack": "node ../../scripts/verify-yep-node.mjs",
|
|
28
30
|
"build": "node bin/yep.js --version",
|
|
29
31
|
"check": "node --check bin/yep.js && node --check bin/market.js && node --check lib/device/command.js && node --check lib/device/client.js && node --check lib/device/tools.js",
|
|
30
32
|
"test": "node --test"
|