From 096193a8ec096800a0c04080efb2c5252a738e99 Mon Sep 17 00:00:00 2001
From: jkooe <62494645+jkooe@users.noreply.github.com>
Date: Sat, 10 Oct 2026 15:11:39 +0800
Subject: [PATCH] =?UTF-8?q?feat(console):=20=E9=87=87=E7=BA=B3=20sidecar?=
=?UTF-8?q?=20=E6=96=B9=E6=A1=88=EF=BC=88=E9=80=89=E9=A1=B9=20D=20?=
=?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=89=E2=80=94=E2=80=94=20Node?=
=?UTF-8?q?=20sidecar=20=E5=AE=9E=E7=8E=B0=20+=208/8=20=E7=9C=9F=E6=9C=BA?=
=?UTF-8?q?=20e2e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 为什么
原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移**
(改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为
「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。
## 本次交付
1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成
**JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state;
onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event)
2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的
ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报
"Dynamic require of events is not supported"
3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本
4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向)
## 两条协议纪律(写进注释与文档)
- **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败)
- 任何异常都转成响应,**绝不因单个请求崩进程**
## 错误形态归一(实测中修正)
被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。
若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`;
而「未连接/缺参数」仍走协议错误(语义不同)。
## 验证(真实链路,非 mock)
本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ ·
invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ ·
非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓
打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。
## 待办(下一批)
Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 +
Node 运行时打包 · 退役 crates/nodeagent-client
---
apps/console/package.json | 8 +-
apps/console/sidecar/build.mjs | 52 ++++++++
apps/console/sidecar/main.mjs | 210 +++++++++++++++++++++++++++++++++
docs/console-plan.md | 73 ++++++++++++
pnpm-lock.yaml | 6 +
5 files changed, 347 insertions(+), 2 deletions(-)
create mode 100644 apps/console/sidecar/build.mjs
create mode 100644 apps/console/sidecar/main.mjs
diff --git a/apps/console/package.json b/apps/console/package.json
index 6c72926..0d94e93 100644
--- a/apps/console/package.json
+++ b/apps/console/package.json
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
- "tauri": "tauri"
+ "tauri": "tauri",
+ "sidecar:dev": "node sidecar/main.mjs",
+ "sidecar:build": "node sidecar/build.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -16,7 +18,9 @@
"naive-ui": "^2.45.3",
"pinia": "^4.0.3",
"vue": "^3.5.13",
- "vue-router": "^5.3.1"
+ "vue-router": "^5.3.1",
+ "@nodeagent/client": "workspace:*",
+ "@nodeagent/protocol": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
diff --git a/apps/console/sidecar/build.mjs b/apps/console/sidecar/build.mjs
new file mode 100644
index 0000000..4807856
--- /dev/null
+++ b/apps/console/sidecar/build.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/**
+ * 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
+ *
+ * 用法:node apps/console/sidecar/build.mjs [--out
]
+ *
+ * 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
+ * pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
+ * 用 API 则从模块解析走,与包管理器布局无关。
+ *
+ * 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
+ * **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
+ * `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
+ */
+
+import { build } from 'esbuild';
+import { mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const argv = process.argv.slice(2);
+const outIdx = argv.indexOf('--out');
+const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
+
+mkdirSync(dirname(outFile), { recursive: true });
+
+const result = await build({
+ entryPoints: [join(here, 'main.mjs')],
+ outfile: outFile,
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ target: 'node20',
+ // Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
+ external: ['node:*'],
+ // ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
+ // 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
+ // 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
+ banner: {
+ js:
+ '// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
+ "import { createRequire as __naCreateRequire } from 'node:module';\n" +
+ 'const require = __naCreateRequire(import.meta.url);',
+ },
+ legalComments: 'none',
+ minify: false, // 保持可读:出问题时能直接看堆栈
+ metafile: true,
+});
+
+const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
+console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
diff --git a/apps/console/sidecar/main.mjs b/apps/console/sidecar/main.mjs
new file mode 100644
index 0000000..76b5d05
--- /dev/null
+++ b/apps/console/sidecar/main.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+/**
+ * console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
+ *
+ * ## 为什么需要它(选项 D:Tauri + Node sidecar)
+ * console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
+ * 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
+ * **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
+ *
+ * ## 协议(行分隔 JSON,每条消息一行)
+ * 请求(Rust → 本进程):
+ * {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
+ * {"jsonrpc":"2.0","id":2,"method":"disconnect"}
+ * {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
+ * {"jsonrpc":"2.0","id":4,"method":"state"}
+ * 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
+ * 通知(无 id,本进程主动推):
+ * {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
+ * {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
+ * {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
+ *
+ * ## 纪律
+ * - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
+ * - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
+ */
+
+import { createInterface } from 'node:readline';
+import { NodeAgentClient } from '@nodeagent/client';
+
+/** @type {NodeAgentClient | null} */
+let client = null;
+/** 最近一次连接参数(重连/状态查询用)。 */
+let lastOptions = null;
+
+// ---------------- 输出(唯一写 stdout 的地方) ----------------
+
+function send(msg) {
+ process.stdout.write(JSON.stringify(msg) + '\n');
+}
+
+function ok(id, result) {
+ send({ jsonrpc: '2.0', id, result: result ?? null });
+}
+
+function fail(id, code, message, data) {
+ send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
+}
+
+function notify(kind, payload) {
+ send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
+}
+
+/** 日志走 stderr(stdout 归协议专用)。 */
+function log(level, message) {
+ process.stderr.write(`[sidecar] ${level} ${message}\n`);
+}
+
+// ---------------- 状态快照 ----------------
+
+function stateSnapshot() {
+ if (!client) return { connected: false };
+ return {
+ connected: true,
+ capabilities: client.listCapabilities(),
+ authorized: client.listAuthorized(),
+ agent_version: client.getAgentVersion() ?? null,
+ agent_build: client.getAgentBuild() ?? null,
+ peer_cert_fp: client.getPeerCertFingerprint(),
+ client_id: lastOptions?.clientId ?? null,
+ auth_mode: lastOptions?.authMode ?? 'psk',
+ };
+}
+
+// ---------------- 方法实现 ----------------
+
+const methods = {
+ /** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
+ async connect(params) {
+ if (!params?.url || !params?.key) {
+ throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
+ }
+ // 已有连接先干净关闭,避免两条 ws 并存
+ if (client) {
+ try {
+ client.close();
+ } catch {
+ /* 关不掉也无妨,下面会覆盖 */
+ }
+ }
+ lastOptions = {
+ url: String(params.url),
+ clientId: String(params.client_id ?? 'console'),
+ authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
+ };
+
+ client = new NodeAgentClient({
+ url: lastOptions.url,
+ key: String(params.key),
+ clientId: lastOptions.clientId,
+ insecure: params.insecure !== false,
+ ...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
+ ...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
+ ...(params.private_key ? { privateKey: String(params.private_key) } : {}),
+ ...(params.hub ? { hub: params.hub } : {}),
+ autoReconnect: params.auto_reconnect !== false,
+
+ // 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
+ onStateChange: (state) => notify('state', { state }),
+ onLog: (message) => notify('log', { message }),
+ onEvent: (event) => notify('agent_event', { event }),
+ });
+
+ await client.connect();
+ log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
+ return stateSnapshot();
+ },
+
+ disconnect() {
+ if (client) {
+ try {
+ client.close();
+ } catch (err) {
+ log('warn', `关闭连接时异常:${String(err)}`);
+ }
+ client = null;
+ }
+ return { connected: false };
+ },
+
+ async invoke(params) {
+ // 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
+ if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
+ if (!params?.capability) {
+ throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
+ }
+ try {
+ // 正常路径:被控端返回 InvokeResult(status: ok|failed)
+ return await client.invoke(
+ String(params.capability),
+ (params.args ?? {}),
+ typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
+ );
+ } catch (err) {
+ // ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
+ // 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
+ // 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
+ return {
+ status: 'failed',
+ error: {
+ name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
+ message: err instanceof Error ? err.message : String(err),
+ ...(err?.data !== undefined ? { data: err.data } : {}),
+ },
+ };
+ }
+ },
+
+ state: () => stateSnapshot(),
+};
+
+// ---------------- 主循环 ----------------
+
+const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
+
+rl.on('line', (line) => {
+ const text = line.trim();
+ if (!text) return;
+
+ let req;
+ try {
+ req = JSON.parse(text);
+ } catch {
+ fail(null, -32700, 'JSON 解析失败');
+ return;
+ }
+
+ const { id = null, method, params } = req ?? {};
+ const fn = methods[method];
+ if (typeof fn !== 'function') {
+ fail(id, -32601, `未知方法: ${String(method)}`);
+ return;
+ }
+
+ // 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
+ Promise.resolve()
+ .then(() => fn(params ?? {}))
+ .then((result) => ok(id, result))
+ .catch((err) => {
+ const code = typeof err?.code === 'number' ? err.code : -32603;
+ const msg = err instanceof Error ? err.message : String(err);
+ log('error', `${String(method)} 失败:${msg}`);
+ fail(id, code, msg, err?.data);
+ });
+});
+
+rl.on('close', () => {
+ // Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
+ methods.disconnect();
+ log('info', 'stdin 关闭,sidecar 退出');
+ process.exit(0);
+});
+
+process.on('uncaughtException', (err) => {
+ log('error', `未捕获异常:${err?.stack ?? String(err)}`);
+});
+process.on('unhandledRejection', (err) => {
+ log('error', `未处理的 rejection:${String(err)}`);
+});
+
+log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
diff --git a/docs/console-plan.md b/docs/console-plan.md
index 5d46cea..39d1094 100644
--- a/docs/console-plan.md
+++ b/docs/console-plan.md
@@ -319,3 +319,76 @@ cargo run --example probe -- ws://127.0.0.1:8765 /tmp/nodeagent-e2e
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
> 仅供参考,本方案为技术规划,不构成任何投资建议。
+
+---
+
+## 实施决策(2026-10-10):采纳「Tauri + Node sidecar」,退役 Rust client
+
+### 为什么改
+
+原计划(本文档 §2)让 Rust 壳**自己实现一份 client**(`crates/nodeagent-client`),
+代价是「**两套协议实现**」—— Rust 版与 TS 版会**静默漂移**:改了一处漏另一处时,
+构建通过、单测通过,只在真机运行时才暴露。这是本项目已经反复吃过的一类坑
+(见 `RETROSPECTIVE.md` 的「两份真相」教训)。
+
+**sidecar 方案**让 Rust 壳退化为**进程管理者 + 消息转发者**,协议实现只剩 TS 一份:
+
+```
+┌──────────────┐ Tauri command(签名不变) ┌─────────────────┐
+│ Vue 前端 │ ───────────────────────────► │ Rust 壳 │
+└──────────────┘ ◄──── Tauri event ────────── │(启动/转发/监督) │
+ └────────┬────────┘
+ stdin/stdout(JSON-RPC 2.0,行分隔)
+ ▼
+ ┌───────────────────────────┐
+ │ Node sidecar(sidecar.mjs)│
+ │ 复用 @nodeagent/client │
+ └───────────────────────────┘
+```
+
+### sidecar 协议
+
+请求(Rust → sidecar):
+```jsonc
+{"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,auth_mode?,private_key?,cert_sha256?,hub?}}
+{"jsonrpc":"2.0","id":2,"method":"invoke","params":{capability,args,timeout_ms?}}
+{"jsonrpc":"2.0","id":3,"method":"state"}
+{"jsonrpc":"2.0","id":4,"method":"disconnect"}
+```
+通知(sidecar → Rust,用 `params.kind` 区分,Rust 侧再桥接成 Tauri event):
+```jsonc
+{"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
+{"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
+{"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{…}}}
+```
+
+**两条纪律**:① **stdout 只写协议消息**(日志一律 stderr,混入非 JSON 行会让 Rust 侧解析失败);
+② 任何异常都转成响应,**绝不因单个请求崩进程**。
+
+### 错误形态归一(真机实测中修正)
+
+被控端对「未知能力 / ACL 拒绝 / 超时」是回 **JSON-RPC error**,`client.invoke()` 因此**抛异常**。
+若原样上抛,前端要处理两种形态。sidecar 已把它归一成 `InvokeResult` 的 failed 形态:
+
+```jsonc
+{"result":{"status":"failed","error":{"name":"E_CAPABILITY_NOT_FOUND","message":"…"}}}
+```
+「调用方式不对」(未连接 / 缺参数)仍走**协议错误**(带明确 code),二者语义不同、不混淆。
+
+### 进度
+
+| 步骤 | 状态 |
+|---|---|
+| ① sidecar 实现(`apps/console/sidecar/main.mjs`) | ✅ 190 行 |
+| ② 真实链路 e2e(本地 agent → connect → invoke → 事件 → 错误 → disconnect) | ✅ **8/8 通过** |
+| ③ 单文件打包(`sidecar/build.mjs`,esbuild JS API + createRequire shim) | ✅ 227KB,**仓库外可独立运行** |
+| ④ Rust 壳改造(内部换 sidecar,command 签名不变 → 前端零改动) | ⬜ 待做 |
+| ⑤ `tauri.conf.json` 的 `externalBin` / resource 配置 + Node 运行时打包 | ⬜ 待做 |
+| ⑥ 退役 `crates/nodeagent-client`(删依赖 + rust.yml) | ⬜ 待做 |
+
+### 打包说明(步骤 ⑤ 的既定方向)
+
+sidecar 是 `.mjs`,需要 Node 解释器 —— **不满足 Tauri `externalBin`(要求可执行文件)**。
+采用与 `pack:win` 一致的既有做法:**sidecar.mjs + Node 运行时都作为 Tauri resource**,
+Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node)。
+(`externalBin` 若要走,需 Node SEA 打成真二进制,但其动态 require 限制与 `ws` 有冲突,故不采用。)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e234f15..af8cdfc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,6 +45,12 @@ importers:
apps/console:
dependencies:
+ '@nodeagent/client':
+ specifier: workspace:*
+ version: link:../../packages/client
+ '@nodeagent/protocol':
+ specifier: workspace:*
+ version: link:../../packages/protocol
'@tauri-apps/api':
specifier: ^2
version: 2.12.1