@yep-ai/cli 1.0.1 → 1.0.3

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 CHANGED
@@ -5,14 +5,14 @@
5
5
  ## 安装与使用
6
6
 
7
7
  ```sh
8
- npx --yes @yep-ai/cli@latest --help
8
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep --help
9
9
  ```
10
10
 
11
- 通过 npx 直接运行 npm 最新版,无需全局安装:
11
+ 通过 npx 直接运行本站已发布的设备节点安装包,无需全局安装:
12
12
 
13
13
  ```sh
14
- npx --yes @yep-ai/cli@latest --help
15
- npx --yes @yep-ai/cli@latest device start --server https://yep.nihao.cool
14
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep --help
15
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device start
16
16
  ```
17
17
 
18
18
  本包包含完整实现。`yep-cli` 是计划中的同版本入口包,当前尚未发布本项目版本,请使用 `@yep-ai/cli`。
@@ -24,32 +24,33 @@ npx --yes @yep-ai/cli@latest device start --server https://yep.nihao.cool
24
24
  ```sh
25
25
  npm install
26
26
  npm run yep -- --help
27
+ npm run build:yep-node
27
28
  npm run pack:cli
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
29
+ npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.3.tgz ./dist/cli/unscoped/yep-cli-1.0.3.tgz
29
30
  ./tmp/yep-install/node_modules/.bin/yep --version
30
31
  ```
31
32
 
32
- 完整实现包为 `scoped/yep-ai-cli-1.0.1.tgz`,入口包为 `unscoped/yep-cli-1.0.1.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
33
+ 完整实现包为 `scoped/yep-ai-cli-1.0.3.tgz`,入口包为 `unscoped/yep-cli-1.0.3.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
33
34
 
34
35
  ## 市场与 Git 内容版本
35
36
 
36
37
  ```sh
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
38
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep login --registry https://yep.nihao.cool
39
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep whoami
40
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep search assistant
41
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep info @creator/demo
42
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep client set codex,claude
43
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep install @creator/demo --client codex
44
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep list
45
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep list --remote
46
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep list --mine
47
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep uninstall @creator/demo --client codex
48
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep publish ./my-skill
49
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep history @creator/demo
50
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep diff @creator/demo --base published
51
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep publish ./my-agent --kind agent
52
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep submit @creator/demo --kind agent
53
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep withdraw @creator/demo --kind agent
53
54
  ```
54
55
 
55
56
  默认资源类型为 `skill`,也支持 `--kind agent|plugin`。Skill 私有保存后从网页申请共享审核;Agent/Plugin 的发布会提交审核。审核通过才进入公开市场。`history` 和 `diff` 访问服务端保留的真实 Git 版本。
@@ -59,19 +60,24 @@ npx --yes @yep-ai/cli@latest withdraw @creator/demo --kind agent
59
60
  ## 设备节点
60
61
 
61
62
  ```sh
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
63
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device login
64
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device start "/path/to/documents"
65
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device status
66
+ npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device logout
66
67
  ```
67
68
 
68
69
  设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5 个目录;指定目录时仅提供受限内置命令。不指定目录时,命令拥有当前系统用户的 shell 权限,可访问其他文件和网络。进程退出后节点离线,不安装后台服务。设备授权最长 24 小时,可以从网页或 CLI 撤销。
69
70
 
70
- 节点上线后,可在「设备节点」点击「打开终端」,直接选择启动目录并发送命令,无需创建咨询。终端实时展示标准输出、错误输出和退出码,可以停止命令、清屏和重连。`cd 子目录`、`cd ..` 与 `cd` 可切换工作目录;每条命令独立执行,shell 变量不会跨命令保留。CLI 终端不提供交互式输入,`vim`、`top`、口令提示等需要真实终端的程序请使用 SSH 网页终端。单条命令最长运行 30 秒、输出最多 64 KiB;关闭终端、撤销授权或断线会停止正在执行的命令。
71
+ 节点上线后,可在「设备节点」点击「打开终端」。不指定启动目录时,npm 包内的 Go `yep-node` 为每个标签启动独立的真实 Shell:Windows 使用 ConPTY + cmd,macOS / Linux 使用 PTY + 当前用户的 Shell。输入直接进入终端,工作目录、变量和交互程序状态保留;支持 Ctrl+C、窗口尺寸变化和最多 4 个独立网页会话。AI 操作与网页终端分别执行,不共用工作目录或进程。关闭标签、撤销授权、节点退出或网络连接断开会终止对应 Shell;重连创建新会话,不重放命令。
71
72
 
72
- 指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes @yep-ai/cli@latest`,保留原目录参数与 `--server` 地址。
73
+ 终端中的「+」可选择文件浏览器,Windows 还可选择 CMD 或 PowerShell。标签显示实际 Shell 名称,同名时才编号;仅当前标签显示关闭按钮。文件浏览器显示设备上的完整路径,选中文件可在底部展开大小、修改时间等概要,通过自定义右键菜单下载文件或复制完整路径。受限模式只能访问已授权目录,并继续排除 CLI 私有凭据及符号链接;下载期间文件变化、设备断线或授权失效会中断下载。
73
74
 
74
- `--server` 只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站;令牌按源站隔离。市场登录不等于设备授权。
75
+ 原生程序随 npm 包一起分发,无需用户安装 Go。当前构建目标为 Windows x64 / ARM64、macOS Intel / Apple Silicon、Linux x64 / ARM64;Windows 需要支持 ConPTY 的 Windows 10 1809 / Server 2019 或更新版本。鸿蒙 / OpenHarmony 原生 ABI 尚未适配,不能将 Linux ARM64 包视为鸿蒙包。缺少原生程序时会明确提示并提供兼容的逐条命令模式;受限目录始终使用内置命令模式,不启动系统 Shell。
76
+
77
+ 设备配对和网络通道使用 HTTPS / WSS;服务端保存令牌摘要,客户端每小时轮换设备令牌并保留短暂交接窗口,轮换不会延长原有 24 小时授权。客户端先持久化待交接凭据,断线后继续完成交接。TLS 在站点入口终止,集群内部连接不属于端到端加密。
78
+ 指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep`,保留原目录参数。
79
+
80
+ 设备默认连接 `https://yep.nihao.cool`,使用本站时无需指定服务器。连接自建站点时可使用 `--server https://your-site.example`;只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站,令牌按源站隔离。市场登录不等于设备授权。
75
81
 
76
82
  ## 配置
77
83
 
@@ -89,4 +95,4 @@ npx --yes @yep-ai/cli@latest device logout
89
95
 
90
96
  市场凭据保存为 `~/.config/yep/config.json`;设备身份和各源站的授权保存在 `~/.config/yep/devices/`。目录权限 `0700`、凭据文件 `0600`;Windows 使用当前用户专属 ACL。市场和设备令牌分别管理。域名改变后需要在新源站重新登录、配对,不会把旧源站令牌发送到新地址。
91
97
 
92
- 执行 `npx --yes @yep-ai/cli@latest --help` 或 `npx --yes @yep-ai/cli@latest device --help` 查看全部选项。
98
+ 执行 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep --help` 或 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.0.3.tgz yep device --help` 查看全部选项。
@@ -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, terminalDirectory } from "./tools.js";
7
+ import { rootDescriptor, resolveSafe, list, execute, terminalDirectory, ownerFileList, ownerFileRead } 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
- await fs.writeFile(temporary, JSON.stringify({ ...value, server }), { mode: 384, flag: "wx" });
88
- await protect(temporary);
89
- await fs.rename(temporary, location);
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
- throw new Error("Server returned an invalid response");
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;
@@ -279,6 +297,29 @@ class DeviceAgent {
279
297
  throw new Error("Device authorization expired");
280
298
  }
281
299
  if (["terminal_exec", "terminal_cancel", "terminal_close"].includes(frame.type)) return this.terminalFrame(frame);
300
+ if (["owner_file_list", "owner_file_read"].includes(frame.type)) {
301
+ if (typeof frame.id !== "string" || !frame.id || this.active.has(frame.id) || this.active.size >= 8) throw new Error("Too many or duplicate local operations");
302
+ let root = this.roots.find(item => item.id === frame.folderId) ?? (!frame.folderId ? this.roots[0] : undefined);
303
+ let relative = frame.path ?? "";
304
+ if (frame.absolutePath) {
305
+ if (typeof frame.absolutePath !== "string" || frame.absolutePath.length > 4096 || !path.isAbsolute(frame.absolutePath) || /[\0\r\n]/u.test(frame.absolutePath)) throw new Error("Invalid absolute path");
306
+ const location = path.resolve(frame.absolutePath);
307
+ const matches = this.roots.filter(item => { const rel = path.relative(item.absolute, location); return rel === "" || rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel); }).sort((a, b) => b.absolute.length - a.absolute.length);
308
+ root = matches[0];
309
+ if (!root) throw new Error("Path is outside authorized directories");
310
+ relative = path.relative(root.absolute, location).split(path.sep).join("/");
311
+ }
312
+ if (!root) throw new Error("Unknown file root");
313
+ const abort = new AbortController();
314
+ let complete;
315
+ const done = new Promise(resolve => { complete = resolve; });
316
+ this.active.set(frame.id, { abort, done });
317
+ try {
318
+ const result = frame.type === "owner_file_list" ? await ownerFileList(root, relative, frame.offset ?? 0) : await ownerFileRead(root, relative, frame.offset ?? 0, frame.version ?? "");
319
+ if (this.closed || abort.signal.aborted) throw new Error("Operation canceled");
320
+ return { type: "result", id: frame.id, ...result, ...(frame.type === "owner_file_list" ? { folders: this.roots.map(({ id, name, absolute }) => ({ id, name, absolutePath: absolute })) } : {}) };
321
+ } finally { this.active.delete(frame.id); complete(); }
322
+ }
282
323
  const base = { id: frame.id, consultationId: frame.consultationId, bindingId: frame.bindingId, roundId: frame.roundId };
283
324
  if (frame.type === "cancel") {
284
325
  await this.cancel(frame);
@@ -353,11 +394,53 @@ async function start(server, config, credential, directories, { name, noOpen, si
353
394
  if (directories.length > 5) throw new Error("At most five startup directories");
354
395
  const roots = restricted ? await Promise.all(directories.map((p, i) => rootDescriptor("root-" + (i + 1), p, config.directory))) : await systemRoots(config.directory);
355
396
  if (!roots.length) throw new Error("No accessible filesystem roots");
397
+ const nativeBinary = restricted ? null : await findNativeBinary();
398
+ if (!restricted && !nativeBinary) console.warn("未找到当前平台的原生终端组件,使用兼容命令模式。");
356
399
  console.log(restricted ? "受限目录模式:仅内置命令,不能启动 shell 或外部程序。" : "完整系统模式:网页授权执行的命令拥有当前系统用户权限,可访问其他文件和网络;这不是沙箱。");
357
400
  let delay = 1e3;
401
+ const rotationInterval = 60 * 60 * 1000;
402
+ // Older credentials lack issuedAt. Their fixed 24-hour lifetime still gives
403
+ // an issuance bound, so reconnecting/restarting cannot postpone rotation.
404
+ const rotationDeadline = value => {
405
+ const base = Date.parse(value.rotatedAt || value.issuedAt);
406
+ const issued = Date.parse(value.expiresAt) - 24 * rotationInterval;
407
+ return (Number.isFinite(base) ? Math.min(base, Date.now()) : Math.min(issued, Date.now())) + rotationInterval;
408
+ };
409
+ let nextRotation = rotationDeadline(credential);
410
+ const retryDelay = () => new Promise(resolve => {
411
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); };
412
+ const timer = setTimeout(finish, delay);
413
+ signal?.addEventListener("abort", finish, { once: true });
414
+ if (signal?.aborted) finish();
415
+ });
416
+ const recoverCredential = async () => {
417
+ const saved = credential.deviceId ? await loadCredential(config.directory, server) : null;
418
+ if (!saved) return;
419
+ if (saved.deviceId !== credential.deviceId) {
420
+ const error = new Error("Device credential changed");
421
+ error.code = "YEP_CREDENTIAL_CHANGED";
422
+ throw error;
423
+ }
424
+ credential = saved.pendingToken ? await rotateDeviceCredential(server, saved, { api, save: value => saveCredential(config.directory, server, value) }) : saved;
425
+ nextRotation = Math.min(nextRotation, rotationDeadline(credential));
426
+ if (!credential.pendingToken && saved.pendingToken) nextRotation = rotationDeadline(credential);
427
+ };
358
428
  while (!signal?.aborted) {
429
+ try {
430
+ // A previous rotate response or final save may have been lost. Recover
431
+ // from disk before presenting a bearer on a new WebSocket connection.
432
+ await recoverCredential();
433
+ } catch (error) {
434
+ if (error.code === "YEP_CREDENTIAL_CHANGED") throw error;
435
+ if ([401, 403].includes(error.status)) throw new Error("Device authorization expired or revoked");
436
+ console.log(`设备凭据同步暂时失败;${Math.ceil(delay / 1e3)}秒后重试。`);
437
+ await retryDelay();
438
+ delay = Math.min(delay * 2, 3e4);
439
+ continue;
440
+ }
359
441
  if (Date.now() >= Date.parse(credential.expiresAt)) throw new Error("Device authorization expired; run login again");
360
442
  let fatal;
443
+ let recoverableAuthorization = false;
361
444
  await new Promise((resolve, reject) => {
362
445
  const url = new URL("/api/local-cli/connect", server);
363
446
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -365,53 +448,104 @@ async function start(server, config, credential, directories, { name, noOpen, si
365
448
  const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt,
366
449
  onTerminalOutput: frame => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame)); },
367
450
  });
451
+ const native = nativeBinary ? new NativeTerminal(nativeBinary, frame => {
452
+ if (ws.readyState !== WebSocket.OPEN) return;
453
+ if (ws.bufferedAmount > 2 * 1024 * 1024) { ws.terminate(); return; }
454
+ ws.send(JSON.stringify(frame));
455
+ }) : null;
368
456
  let ready = false;
369
457
  let heartbeat;
458
+ let receiveWatchdog;
459
+ let credentialRotation = false;
460
+ let rotationTask = null;
461
+ let awaitingCredential = false;
462
+ const received = () => {
463
+ if (ws.readyState !== WebSocket.OPEN || fatal || signal?.aborted) return;
464
+ clearTimeout(receiveWatchdog);
465
+ receiveWatchdog = setTimeout(() => {
466
+ ready = false;
467
+ native?.close();
468
+ agent.close();
469
+ ws.terminate();
470
+ }, 45000);
471
+ };
472
+ const updateCredential = () => {
473
+ if (awaitingCredential && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "credential_update", token: credential.token }));
474
+ };
475
+ const rotate = () => {
476
+ if (!credentialRotation || rotationTask || awaitingCredential || !ready || signal?.aborted) return;
477
+ rotationTask = (async () => {
478
+ const saved = await loadCredential(config.directory, server);
479
+ if (!saved || saved.deviceId !== credential.deviceId) throw new Error("Device credential changed");
480
+ const next = await rotateDeviceCredential(server, saved, { api, save: value => saveCredential(config.directory, server, value) });
481
+ credential = next;
482
+ agent.token = next.token;
483
+ nextRotation = Date.now() + rotationInterval;
484
+ if (ws.readyState === WebSocket.OPEN && ready) { awaitingCredential = true; updateCredential(); }
485
+ })().catch(() => { nextRotation = Date.now() + 60 * 1000; }).finally(() => { rotationTask = null; });
486
+ };
370
487
  const cleanup = () => {
371
488
  clearInterval(heartbeat);
489
+ clearTimeout(receiveWatchdog);
490
+ native?.close();
372
491
  agent.close();
373
492
  signal?.removeEventListener("abort", abort);
374
493
  };
375
494
  const abort = () => {
376
495
  ready = false;
496
+ native?.close();
377
497
  agent.close();
378
498
  ws.close();
379
499
  };
380
500
  signal?.addEventListener("abort", abort, { once: true });
381
501
  ws.on("open", () => {
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"] }));
502
+ received();
503
+ 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", "files-v1", ...(native ? ["pty", "shell-selection"] : [])] }));
383
504
  });
505
+ ws.on("ping", received);
506
+ ws.on("pong", received);
384
507
  ws.on("unexpected-response", (_req, res) => {
385
508
  if ([401, 403].includes(res.statusCode)) {
386
509
  fatal = new Error("Device authorization expired or revoked");
387
- ws.terminate();
510
+ recoverableAuthorization = true;
388
511
  }
512
+ // With this listener installed ws leaves response ownership to us.
513
+ // Explicitly end every rejected handshake, including transient 503s.
514
+ res.destroy();
515
+ ws.terminate();
389
516
  });
390
517
  ws.on("message", async (bytes) => {
391
518
  if (fatal || signal?.aborted) return;
519
+ received();
392
520
  let frame;
393
521
  try {
394
522
  frame = JSON.parse(bytes.toString());
395
523
  if (frame.type === "ready") {
396
524
  if (frame.protocol !== PROTOCOL) throw new Error("Unsupported server protocol");
397
525
  agent.connectionId = frame.connectionId;
526
+ credentialRotation = frame.credentialRotation === true;
398
527
  ready = true;
399
528
  delay = 1e3;
400
529
  console.log("设备已上线。可在网页设备节点中打开终端,或在咨询中选择目录;退出此进程将断开。");
401
530
  heartbeat = setInterval(() => {
402
531
  if (Date.now() >= Date.parse(credential.expiresAt)) {
403
532
  fatal = new Error("Device authorization expired");
533
+ native?.close();
404
534
  agent.close();
405
535
  ws.close();
406
536
  return;
407
537
  }
408
538
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
539
+ updateCredential();
540
+ if (Date.now() >= nextRotation) rotate();
409
541
  }, 1e4);
410
542
  return;
411
543
  }
412
544
  if (["revoked", "expired", "replaced"].includes(frame.type)) {
413
545
  fatal = new Error(`Device ${frame.type}; local operations stopped`);
546
+ recoverableAuthorization = frame.type === "revoked";
414
547
  ready = false;
548
+ native?.close();
415
549
  agent.close();
416
550
  ws.close();
417
551
  return;
@@ -420,7 +554,18 @@ async function start(server, config, credential, directories, { name, noOpen, si
420
554
  ws.send(JSON.stringify({ type: "pong" }));
421
555
  return;
422
556
  }
557
+ if (frame.type === "credential_updated") { awaitingCredential = false; return; }
558
+ if (frame.type === "credential_update_error") { awaitingCredential = true; return; }
423
559
  if (!ready) return;
560
+ if (["pty_open", "pty_input", "pty_resize", "pty_close"].includes(frame.type)) {
561
+ try {
562
+ if (!native) throw new Error("Interactive terminal unavailable");
563
+ native.write(frame);
564
+ } catch (error) {
565
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "pty_error", sessionId: frame.sessionId, error: String(error.message).slice(0, 1024) }));
566
+ }
567
+ return;
568
+ }
424
569
  const result = await agent.frame(frame);
425
570
  if (result && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(result));
426
571
  } catch (e) {
@@ -431,27 +576,30 @@ async function start(server, config, credential, directories, { name, noOpen, si
431
576
  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) }));
432
577
  } else {
433
578
  fatal = e;
579
+ native?.close();
580
+ agent.close();
434
581
  ws.close();
435
582
  }
436
583
  }
437
584
  });
438
585
  ws.on("error", () => {
439
586
  });
440
- ws.on("close", () => {
587
+ ws.on("close", async () => {
441
588
  cleanup();
589
+ if (rotationTask) await rotationTask;
442
590
  resolve();
443
591
  });
444
592
  });
445
- if (fatal) throw fatal;
593
+ if (fatal) {
594
+ if (!recoverableAuthorization) throw fatal;
595
+ const saved = credential.deviceId ? await loadCredential(config.directory, server) : null;
596
+ if (!saved?.pendingToken) throw fatal;
597
+ // Local operations have already stopped. A pending secret can distinguish
598
+ // an expired overlap from an actual revocation on the next recovery pass.
599
+ }
446
600
  if (signal?.aborted) break;
447
601
  console.log(`连接已断开;${Math.ceil(delay / 1e3)}秒后重连。原咨询绑定已失效,重连后需重新选择。`);
448
- await new Promise((resolve) => {
449
- const t = setTimeout(resolve, delay);
450
- signal?.addEventListener("abort", () => {
451
- clearTimeout(t);
452
- resolve();
453
- }, { once: true });
454
- });
602
+ await retryDelay();
455
603
  delay = Math.min(delay * 2, 3e4);
456
604
  }
457
605
  }
@@ -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
+ }
@@ -3,6 +3,7 @@ import { constants as FS } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { StringDecoder } from "node:string_decoder";
6
+ import { windowsExecution } from "./windows-execution.js";
6
7
  const MAX_FILE = 10 * 1024 * 1024;
7
8
  const MAX_OUTPUT = 64 * 1024;
8
9
  function safeRelative(value = "") {
@@ -104,6 +105,36 @@ async function list(root, relative = "", offset = 0, includeFiles = true) {
104
105
  }
105
106
  return { folderId: root.id, path: relative, parentPath: relative ? relative.split("/").slice(0, -1).join("/") : null, entries: page, offset, hasMore: offset + 200 < filtered.length };
106
107
  }
108
+ // Owner-only browser operations. Absolute paths are returned only on this
109
+ // protocol, never in public roots or AI bindings.
110
+ async function ownerFileList(root, relative = "", offset = 0) {
111
+ const result = await list(root, relative, offset, true);
112
+ const entries = [];
113
+ for (const entry of result.entries) {
114
+ try {
115
+ const { absolute, stat } = await resolveSafe(root, entry.path);
116
+ entries.push({ ...entry, absolutePath: absolute, isDirectory: stat.isDirectory(), size: stat.isFile() ? stat.size : 0, modifiedAt: stat.mtime.toISOString() });
117
+ } catch { /* A removed or inaccessible entry does not break the directory. */ }
118
+ }
119
+ return { ...result, nextOffset: result.hasMore ? offset + 200 : null, absolutePath: (await resolveSafe(root, relative, "directory")).absolute, entries };
120
+ }
121
+ async function ownerFileRead(root, relative, offset = 0, version = "") {
122
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid file offset");
123
+ const resolved = await resolveSafe(root, relative, "file");
124
+ const fd = await pinnedOpen(root, relative);
125
+ try {
126
+ const before = await fd.stat();
127
+ const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`;
128
+ if (before.ino !== resolved.stat.ino || before.dev !== resolved.stat.dev || !before.isFile() || version && version !== identity) throw new Error("File changed during download; please retry");
129
+ if (offset > before.size) throw new Error("Invalid file offset");
130
+ const buffer = Buffer.alloc(Math.min(256 * 1024, before.size - offset));
131
+ const { bytesRead } = await fd.read(buffer, 0, buffer.length, offset);
132
+ const after = await fd.stat();
133
+ if (`${after.dev}:${after.ino}:${after.size}:${after.mtimeMs}:${after.ctimeMs}` !== identity) throw new Error("File changed during download; please retry");
134
+ await resolveSafe(root, relative, "file");
135
+ return { name: path.basename(relative), size: before.size, offset, version: identity, data: buffer.subarray(0, bytesRead).toString("base64"), eof: offset + bytesRead === before.size };
136
+ } finally { await fd.close(); }
137
+ }
107
138
  function utf8(data) {
108
139
  const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
109
140
  if (text.includes("\0")) throw new Error("File is not UTF-8 text");
@@ -274,7 +305,8 @@ async function execute(root, request, { restricted = true, signal, secrets = [],
274
305
  return new Promise((resolve, reject) => {
275
306
  const env = { ...process.env };
276
307
  for (const k of Object.keys(env)) if (/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION/iu.test(k)) delete env[k];
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"] });
308
+ const shell = process.platform === "win32" ? windowsExecution(request.command, env) : { executable: "/bin/sh", args: ["-c", request.command] };
309
+ const child = spawn(shell.executable, shell.args, { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
278
310
  let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, streamError;
279
311
  const kill = () => {
280
312
  if (process.platform === "win32") {
@@ -335,6 +367,8 @@ export {
335
367
  execute,
336
368
  fileOperation,
337
369
  list,
370
+ ownerFileList,
371
+ ownerFileRead,
338
372
  resolveSafe,
339
373
  rootDescriptor,
340
374
  safeRelative,
@@ -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": "de2d089833202ddf0943a16360b1d3e61229ffb4aa91c82d293d193c68152f55",
4
+ "files": {
5
+ "win32-x64/yep-node.exe": {
6
+ "sha256": "4d02c921b40636278e1959e1080503210d5f7622a2bd712b026d1155d0195787",
7
+ "goos": "windows",
8
+ "goarch": "amd64"
9
+ },
10
+ "win32-arm64/yep-node.exe": {
11
+ "sha256": "febcb2d56ddac514f16c6aa6d4d272c6113fbb8e82c7e09045fb36c242be9e2a",
12
+ "goos": "windows",
13
+ "goarch": "arm64"
14
+ },
15
+ "darwin-x64/yep-node": {
16
+ "sha256": "0ff37c8c6d45f82d1c5f112306ca5b4d2a16054c50b521536bb12c051df02e05",
17
+ "goos": "darwin",
18
+ "goarch": "amd64"
19
+ },
20
+ "darwin-arm64/yep-node": {
21
+ "sha256": "d94adea76c11039a60098dadab8b09068df18de1c4024202e24536047a007114",
22
+ "goos": "darwin",
23
+ "goarch": "arm64"
24
+ },
25
+ "linux-x64/yep-node": {
26
+ "sha256": "51e6a8173acacc828f4cbc774f026571fb2e7ec5cf6adf7a27581bbe12fd193b",
27
+ "goos": "linux",
28
+ "goarch": "amd64"
29
+ },
30
+ "linux-arm64/yep-node": {
31
+ "sha256": "8924a2a3c09dee1e1532c1dad64c32251bace31b6670bd049041153c1614e3c7",
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.1",
3
+ "version": "1.0.3",
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"