dsh-router-ext-rtk 0.3.12
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/LICENSE +21 -0
- package/README.md +145 -0
- package/cordis.patch.yml +10 -0
- package/lib/index.js +265 -0
- package/package.json +54 -0
- package/src/contract.ts +97 -0
- package/src/index.ts +63 -0
- package/src/intercept.test.ts +162 -0
- package/src/intercept.ts +115 -0
- package/src/rtk.test.ts +33 -0
- package/src/rtk.ts +171 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CARVIN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<h1 align="center">dsh-router-ext-rtk</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">dsh-router 的 RTK 扩展插件</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/dsh-router-ext-rtk"><img src="https://img.shields.io/npm/v/dsh-router-ext-rtk?style=flat-square&logo=npm&label=npm" alt="npm version"></a>
|
|
7
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-10b981?style=flat-square" alt="MIT license"></a>
|
|
8
|
+
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="#快速安装">快速安装</a> ·
|
|
13
|
+
<a href="#能力">能力</a> ·
|
|
14
|
+
<a href="#与核心的分工">与核心的分工</a> ·
|
|
15
|
+
<a href="https://github.com/CARVIN94/dsh-router/blob/main/docs/ext.md">扩展开发</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
为 [dsh-router](https://github.com/CARVIN94/dsh-router) 提供 **RTK(Rust Token Killer)**
|
|
19
|
+
真实代理:把 agent 每次 bash 工具调用改写成 `rtk <cmd>`(如 `git status` →
|
|
20
|
+
`rtk git status`),削减发给 LLM 的 60–90% bash 输出。
|
|
21
|
+
|
|
22
|
+
单独装它没用——核心提供**面板与开关存储**,本插件提供**改写与拦截**。
|
|
23
|
+
|
|
24
|
+
## 快速安装
|
|
25
|
+
|
|
26
|
+
先装核心,再装本插件,然后**重启 `dsh web`**:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile web add dsh-router-core
|
|
30
|
+
dsh plugin --profile web add dsh-router-ext-rtk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`dsh plugin add` 会在 profile 里 `pnpm add`,并自动把声明了 `dsh.bundle.patch`
|
|
34
|
+
的包加入 `dsh.profile.bundles`(本插件即声明了,见 `cordis.patch.yml`)。
|
|
35
|
+
|
|
36
|
+
然后在「设置 → 路由 → **扩展**」页打开 **RTK** 开关。
|
|
37
|
+
|
|
38
|
+
### 前置:本机装 RTK
|
|
39
|
+
|
|
40
|
+
RTK 是 [rtk-ai/rtk](https://github.com/rtk-ai/rtk)(Rust Token Killer),
|
|
41
|
+
**不是** Rust Type Kit(`reachingforthejack/rtk`)。
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
brew install rtk
|
|
45
|
+
# 或
|
|
46
|
+
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
验证:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
rtk --version # 应返回 0.x
|
|
53
|
+
rtk gain # 应显示 token 节省统计(不是 command not found)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**未装 rtk 时开关开不了**:插件启动自检会报 `ready:false`,核心拒绝开启(API 409),
|
|
57
|
+
面板内容区红字提示原因。装上后重启 `dsh web` 即可。
|
|
58
|
+
|
|
59
|
+
## 能力
|
|
60
|
+
|
|
61
|
+
| 能力 | 说明 |
|
|
62
|
+
|---|---|
|
|
63
|
+
| 真实代理 | 每条 bash 命令经 `rtk rewrite` 改写后执行,LLM 读的是压缩后的输出。 |
|
|
64
|
+
| 降级安全 | rtk 缺失 / 无等价改写 / spawn 失败 → 命令原样执行,**绝不报错**。 |
|
|
65
|
+
| 只拦 bash | 其他工具(含 `run_code` 体内自起的子进程)不动。 |
|
|
66
|
+
| 开关自检 | 启动时探测 rtk;不可用时核心拒绝开启并给出问题描述。 |
|
|
67
|
+
| 开关持久化 | 状态存核心的 `<profile>/data/ext.json`,默认关(**插件经 `router.extStore` 读写,不自己存**)。 |
|
|
68
|
+
|
|
69
|
+
> 实测:`ls` 走代理后输出变成 rtk 树形格式(带文件大小与 `... (N filtered)`),
|
|
70
|
+
> `rtk gain` 报 3 条命令省 280 tokens(61.9%)。
|
|
71
|
+
|
|
72
|
+
## 配置
|
|
73
|
+
|
|
74
|
+
| 环境变量 | 作用 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `DSH_RTK_BIN` | 显式指定 rtk 可执行文件路径。缺省走 `PATH`,再兜底常见安装路径(`~/.local/bin/rtk`、`/opt/homebrew/bin/rtk`、`/usr/local/bin/rtk`、`~/.cargo/bin/rtk`)。 |
|
|
77
|
+
|
|
78
|
+
## 与核心的分工
|
|
79
|
+
|
|
80
|
+
核心 = **管理面**,本插件 = **执行面**(2026-09 重构:拦截与裁决从核心归还插件,
|
|
81
|
+
核心只在一个消费者时代挂是纯亏损)。
|
|
82
|
+
|
|
83
|
+
核心负责:持有 `router.ext` 注册表(发现)、渲染面板与 `/router/api/ext`(展示)、
|
|
84
|
+
把开关与插件数据落盘 `<dataDir>/ext.json`(`router.extStore` service)、按
|
|
85
|
+
`getState().ready` 裁决能否开启(不可用时拒 409、面板开关禁开)。
|
|
86
|
+
|
|
87
|
+
本插件负责:**自己挂 `tools/execute` 拦截 bash**、按「核心存的 `enabled` + 自己报的
|
|
88
|
+
`ready`」裁决、命中则用改写后的命令短路执行;`rtk rewrite` 退出码解读与探活也在这里。
|
|
89
|
+
|
|
90
|
+
本插件**无状态**:开关与数据都经 `router.extStore` 读写(`isEnabled` / `readData`),
|
|
91
|
+
不自己 file IO、没有数据目录。
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
src/
|
|
95
|
+
index.ts 插件入口(host 半,注册 rtk 扩展器 + 挂拦截)
|
|
96
|
+
intercept.ts 拦截实现(挂 tools/execute、裁决、短路)—— 无状态
|
|
97
|
+
rtk.ts RTK 扩展器实现(rewrite 退出码解读、rtk 定位/探活)—— 无状态
|
|
98
|
+
contract.ts router.ext 契约副本(自含,与 dsh-router 契约同步)
|
|
99
|
+
*.test.ts 测试
|
|
100
|
+
cordis.patch.yml bundle patch,把插件插入 DSH cordis bundle stack
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> ponytail: 天花板 —— 本插件自己挂监听,多扩展插件各自挂时**顺序不可控**。目前只有
|
|
104
|
+
> rtk 一家,无所谓;第二家出现时要收敛(升级路径:核心暴露顺序委派的共享工具方法,
|
|
105
|
+
> 而不是收回拦截)。
|
|
106
|
+
>
|
|
107
|
+
> **头号坑**:`tools.get('bash', exec.agent)` 的 agent scope 不能省 —— 不带只查全局
|
|
108
|
+
> 视图,查不到 → 静默走原样、从不改写、不报错。已在 `intercept.ts` 文件头 + 测试锁定。
|
|
109
|
+
|
|
110
|
+
完整契约见 [dsh-router 的 `docs/ext.md`](https://github.com/CARVIN94/dsh-router/blob/main/docs/ext.md)。
|
|
111
|
+
|
|
112
|
+
### `rtk rewrite` 退出码
|
|
113
|
+
|
|
114
|
+
RTK 权限默认 least-privilege,**几乎没有命令会落到 Allow(0)**——常见命令
|
|
115
|
+
(`git status` / `ls` / `cat` …)都返回 **Ask(3)**。真实代理下用户已显式开启开关
|
|
116
|
+
= 授权改写,所以退出码 3 也**采用** stdout,不弹二次确认:
|
|
117
|
+
|
|
118
|
+
| 退出码 | 语义 | 行为 |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| 0 | Allow,有等价改写 | 采用 stdout |
|
|
121
|
+
| 1 | 无等价 | 放行(原样执行) |
|
|
122
|
+
| 2 | Deny 规则 | 放行(不做阻断) |
|
|
123
|
+
| 3 | Ask,有等价改写但默认要人工确认 | **采用 stdout**——用户已授权 |
|
|
124
|
+
| — | spawn 失败 / 超时(2s) | 放行 |
|
|
125
|
+
|
|
126
|
+
## 开发
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pnpm install
|
|
130
|
+
pnpm typecheck
|
|
131
|
+
pnpm test
|
|
132
|
+
pnpm build # lib/index.js
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 致谢
|
|
136
|
+
|
|
137
|
+
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk) —— Rust Token Killer,命令输出压缩器。
|
|
138
|
+
|
|
139
|
+
## 许可证
|
|
140
|
+
|
|
141
|
+
[MIT](LICENSE)
|
|
142
|
+
|
|
143
|
+
## 免责声明
|
|
144
|
+
|
|
145
|
+
本项目仅用于学习与技术研究,请勿用于商业用途。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh-router-ext-rtk bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Mounts dsh-router-ext-rtk into the DSH cordis bundle stack. This plugin
|
|
4
|
+
# provides the `router.ext` cordis service (host half) so dsh-router can
|
|
5
|
+
# delegate bash tool calls to the RTK command rewriter. dsh-router holds the
|
|
6
|
+
# `router.ext` aggregate table and defers via ctx.inject, so order vs
|
|
7
|
+
# dsh-router doesn't matter.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-router-ext-rtk
|
|
10
|
+
name: 'dsh-router-ext-rtk'
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
//#region src/rtk.ts
|
|
6
|
+
/**
|
|
7
|
+
* RTK (Rust Token Killer) 扩展器 —— 只负责「改写一条命令」+「报自己是否可用」。
|
|
8
|
+
*
|
|
9
|
+
* 开关状态**不归本插件管**:核心持久化(`<dataDir>/ext.json`)并代存,插件经
|
|
10
|
+
* `router.extStore` service 读,不自己 file IO、没有状态文件。
|
|
11
|
+
*
|
|
12
|
+
* 拦截/裁决/短路也不归这里 —— 见 `intercept.ts`(本插件自己挂 `tools/execute`)。
|
|
13
|
+
*
|
|
14
|
+
* 真实代理语义:对每条 bash 命令调 `rtk rewrite "<cmd>"`,按 rtk 的退出码协议
|
|
15
|
+
* 解读。注意 rtk 的权限默认是 Ask(least-privilege),几乎没有命令会稳定落到
|
|
16
|
+
* Allow(0)——常见命令(git status / ls / cat …)都走 Ask(3)。我们这里做**真实
|
|
17
|
+
* 代理**:用户已经在面板**显式开启**了开关 = 明确授权改写,所以 Ask(3) 也直接
|
|
18
|
+
* 采用 stdout,不弹任何二次确认。只有兜底命令按原文理解:无等价(1)放行、
|
|
19
|
+
* Deny(2)原样(不阻断)、spawn 失败放行。
|
|
20
|
+
*
|
|
21
|
+
* | 退出码 | 语义 | 行为 |
|
|
22
|
+
* |--------|-----------------------------------------------------------|----------------------|
|
|
23
|
+
* | 0 | Allow,有等价改写 | 采用 stdout |
|
|
24
|
+
* | 1 | 无等价,原样 | 放行 |
|
|
25
|
+
* | 2 | Deny 规则(Claude 原生 deny) | 放行(不做阻断) |
|
|
26
|
+
* | 3 | Ask,有等价改写但默认要人工确认 | **采用 stdout**——用户已授权,
|
|
27
|
+
* | | | 真实代理不弹确认框 |
|
|
28
|
+
*
|
|
29
|
+
* 状态(只报运行时事实,不持久化):
|
|
30
|
+
* - `ready`:rtk 二进制是否可用。未装 rtk 时核心会拒绝开启(面板开关禁开、
|
|
31
|
+
* API 拒 409),并在内容区红字显示 `detail`。
|
|
32
|
+
* - `detail`:不就绪时的说明(「本机未装 rtk…」)。
|
|
33
|
+
*
|
|
34
|
+
* rtk 二进制定位优先顺序:
|
|
35
|
+
* 1. `$DSH_RTK_BIN`(显式覆盖)
|
|
36
|
+
* 2. `rtk`(PATH)
|
|
37
|
+
* 3. 常见路径兜底: `~/.local/bin/rtk`、`/opt/homebrew/bin/rtk`、`/usr/local/bin/rtk`、`~/.cargo/bin/rtk`
|
|
38
|
+
*/
|
|
39
|
+
/** rtk rewrite 退出码(与 rtk hooks/claude 一致)。 */
|
|
40
|
+
const RTK_EXIT_ALLOW = 0;
|
|
41
|
+
const RTK_EXIT_NO_REWRITE = 1;
|
|
42
|
+
const RTK_EXIT_DENY = 2;
|
|
43
|
+
const RTK_EXIT_ASK = 3;
|
|
44
|
+
/** 常见 rtk 安装路径(brew / quick-install ~/.local/bin / cargo)。 */
|
|
45
|
+
const FALLBACK_RTK_PATHS = [
|
|
46
|
+
"~/.local/bin/rtk",
|
|
47
|
+
"/opt/homebrew/bin/rtk",
|
|
48
|
+
"/usr/local/bin/rtk",
|
|
49
|
+
"~/.cargo/bin/rtk"
|
|
50
|
+
];
|
|
51
|
+
/** 展开首个 `~/` 前缀。 */
|
|
52
|
+
function expandHome(p) {
|
|
53
|
+
return p.startsWith("~/") || p.startsWith("~\\") ? join(homedir(), p.slice(2)) : p;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 定位 rtk 可执行文件。返回 `'rtk'`(走 PATH,交给 execFileSync 解析)、绝对路径,
|
|
57
|
+
* 或 `undefined`(找不到)。不缓存——`rewrite` 每次重查,避免「装了 rtk 后被缓存的
|
|
58
|
+
* 失败卡死」;探活缓存见 `rtkReady`。
|
|
59
|
+
*/
|
|
60
|
+
function resolveRtkBin(envBin) {
|
|
61
|
+
const explicit = envBin !== void 0 && envBin !== "" ? [envBin] : [];
|
|
62
|
+
for (const raw of [
|
|
63
|
+
...explicit,
|
|
64
|
+
"rtk",
|
|
65
|
+
...FALLBACK_RTK_PATHS
|
|
66
|
+
]) {
|
|
67
|
+
if (raw === "rtk") return "rtk";
|
|
68
|
+
const p = expandHome(raw);
|
|
69
|
+
try {
|
|
70
|
+
accessSync(p, constants.X_OK);
|
|
71
|
+
return p;
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** rtk 可用性探活(缓存:成功存版本,失败存 null)。 */
|
|
76
|
+
let probe = void 0;
|
|
77
|
+
function resetProbe() {
|
|
78
|
+
probe = void 0;
|
|
79
|
+
}
|
|
80
|
+
function rtkReady() {
|
|
81
|
+
if (probe !== void 0) return probe !== null;
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync("rtk", ["--version"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
timeout: 2e3
|
|
86
|
+
}).trim();
|
|
87
|
+
probe = out === "" ? null : out;
|
|
88
|
+
} catch {
|
|
89
|
+
probe = null;
|
|
90
|
+
}
|
|
91
|
+
return probe !== null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 调 `rtk rewrite` 并解释退出码。缺 rtk / spawn 失败 / 无改写 → 放行,永不抛。
|
|
95
|
+
*/
|
|
96
|
+
function tryRewrite(command, bin) {
|
|
97
|
+
let target = bin;
|
|
98
|
+
if (target === void 0) {
|
|
99
|
+
target = resolveRtkBin(process.env.DSH_RTK_BIN);
|
|
100
|
+
if (target === "rtk") try {
|
|
101
|
+
execFileSync("rtk", ["--version"], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
timeout: 2e3
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
return { rewritten: null };
|
|
107
|
+
}
|
|
108
|
+
else if (target === void 0) return { rewritten: null };
|
|
109
|
+
}
|
|
110
|
+
let out;
|
|
111
|
+
let code = 0;
|
|
112
|
+
try {
|
|
113
|
+
out = execFileSync(target, ["rewrite", command], {
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
timeout: 2e3
|
|
116
|
+
}).trim();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const e = err;
|
|
119
|
+
if (typeof e.status !== "number") return { rewritten: null };
|
|
120
|
+
code = e.status;
|
|
121
|
+
out = (e.stdout ?? "").toString().trim();
|
|
122
|
+
if (code === RTK_EXIT_DENY) return { rewritten: null };
|
|
123
|
+
if (code === RTK_EXIT_NO_REWRITE) return { rewritten: null };
|
|
124
|
+
if (code !== RTK_EXIT_ALLOW && code !== RTK_EXIT_ASK) return { rewritten: null };
|
|
125
|
+
if (out === "") return { rewritten: null };
|
|
126
|
+
}
|
|
127
|
+
if ((code === RTK_EXIT_ALLOW || code === RTK_EXIT_ASK) && out !== "" && out !== command) return { rewritten: out };
|
|
128
|
+
return { rewritten: null };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 组装 ext-rtk 的扩展器实例(注册进 router.ext 表)。
|
|
132
|
+
*
|
|
133
|
+
* 注意:不接收 stateFile —— 开关归核心存,插件无状态(除 rtk 探活缓存)。
|
|
134
|
+
* 契约里没有 `rewrite`:核心只认声明 + getState,改命令是本插件自己拦截时用的。
|
|
135
|
+
*/
|
|
136
|
+
function createRtkExt(envBin) {
|
|
137
|
+
rtkReady();
|
|
138
|
+
return {
|
|
139
|
+
id: "rtk",
|
|
140
|
+
name: "RTK",
|
|
141
|
+
description: "bash 命令输出压缩,削减发给 LLM 的 60–90% bash 输出(需本机装 rtk)",
|
|
142
|
+
getState: () => {
|
|
143
|
+
const ready = rtkReady();
|
|
144
|
+
return {
|
|
145
|
+
ready,
|
|
146
|
+
detail: ready ? void 0 : "本机未装 rtk:brew install rtk 或 curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh"
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
dispose: () => {
|
|
150
|
+
resetProbe();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/intercept.ts
|
|
156
|
+
/**
|
|
157
|
+
* bash 调用拦截 —— ext-rtk **自己**挂 `tools/execute`、自己裁决、自己短路。
|
|
158
|
+
*
|
|
159
|
+
* 2026-09 重构:原来这层在 dsh-router 核心(`src/ext/proxy.ts`),核心代挂、代裁决、
|
|
160
|
+
* 代短路。核心只在一个消费者时是纯亏损(共享表 + inject 广播 + 契约两份同步,只为
|
|
161
|
+
* 留 40 行委派),所以归还插件:核心退成**管理面**(注册表面板 + 代存开关/数据)。
|
|
162
|
+
*
|
|
163
|
+
* 挂点语义:`tools/execute` 是 around-dispatch waterfall。我们不调 `next()` 短路整条
|
|
164
|
+
* 链路。改写在 token 冻结之后、工具 body 执行之前发生:sandbox 审批 / guard 等前置
|
|
165
|
+
* 已在 prepare 阶段做过,我们只在**同一授权的调用**里换命令字符串,不绕过任何审批。
|
|
166
|
+
*
|
|
167
|
+
* 关键不变量:
|
|
168
|
+
* - 只拦 `name === 'bash'`。其他工具(含 run_code 体内自起的子进程读取)不动。
|
|
169
|
+
* - 本插件未开启(核心 `extStore.isEnabled('rtk')`)或 rtk 未就绪 → 原样 `next()`。
|
|
170
|
+
* - 命中后直接调 bash 工具定义的 `execute(改写后参数, exec)`,返回
|
|
171
|
+
* `{ isError:false, value }`,由 registry 用名义 arguments(原始命令)re-render。
|
|
172
|
+
* - 任何一步出问题都退回 `next()`:命令永不因本扩展而失败。
|
|
173
|
+
*
|
|
174
|
+
* **头号坑(必须守住)**:`tools.get('bash', exec.agent)` 的第二个参数(agent scope)
|
|
175
|
+
* 不能省。工具注册在 agent scope 里,只查全局视图查不到 'bash' → **静默走 next()、
|
|
176
|
+
* 从不改写、不报错**。症状极具迷惑性(开关全开、命令正常、就是不生效)。
|
|
177
|
+
*
|
|
178
|
+
* ponytail: 天花板 —— 本插件自己挂监听,意味着多扩展插件各自挂时顺序不可控。目前
|
|
179
|
+
* 只有 rtk 一家,无所谓;第二家出现时要收敛(升级路径:核心暴露顺序委派的共享工具
|
|
180
|
+
* 方法,而不是收回拦截)。
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* 挂 bash 拦截。返回清理函数(cordis 的 on 监听随 fiber 卸载自动注销,返回值供显式组合)。
|
|
184
|
+
*
|
|
185
|
+
* @param ctx cordis context(`get('tools')` 懒查工具服务,与加载顺序解耦)
|
|
186
|
+
* @param isEnabled 读核心存的开关(本插件不存开关)
|
|
187
|
+
* @param isReady 本插件自报的运行时就绪
|
|
188
|
+
*/
|
|
189
|
+
function mountRtkIntercept(ctx, isEnabled, isReady, envBin) {
|
|
190
|
+
const toolsOf = () => {
|
|
191
|
+
const t = ctx.get("tools");
|
|
192
|
+
return t && typeof t.get === "function" ? t : void 0;
|
|
193
|
+
};
|
|
194
|
+
const listener = async (exec, next) => {
|
|
195
|
+
if (exec.name !== "bash" || exec.signal.aborted) return next();
|
|
196
|
+
const args = exec.arguments;
|
|
197
|
+
const cmd = args?.command;
|
|
198
|
+
if (typeof cmd !== "string" || cmd.trim() === "") return next();
|
|
199
|
+
if (!isEnabled()) return next();
|
|
200
|
+
if (!isReady()) return next();
|
|
201
|
+
const rewritten = tryRewrite(cmd, envBin).rewritten;
|
|
202
|
+
if (rewritten === null) return next();
|
|
203
|
+
const tools = toolsOf();
|
|
204
|
+
if (!tools) return next();
|
|
205
|
+
const bashTool = tools.get("bash", exec.agent);
|
|
206
|
+
if (!bashTool) return next();
|
|
207
|
+
try {
|
|
208
|
+
return {
|
|
209
|
+
isError: false,
|
|
210
|
+
value: await bashTool.execute({
|
|
211
|
+
...args,
|
|
212
|
+
command: rewritten
|
|
213
|
+
}, exec)
|
|
214
|
+
};
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
return {
|
|
218
|
+
isError: true,
|
|
219
|
+
error: { message },
|
|
220
|
+
content: [{
|
|
221
|
+
type: "text",
|
|
222
|
+
text: `Error: ${message}`
|
|
223
|
+
}]
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
ctx.on("tools/execute", listener);
|
|
228
|
+
return () => {};
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/contract.ts
|
|
232
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)。 */
|
|
233
|
+
function currentExts(ctx) {
|
|
234
|
+
const c = ctx;
|
|
235
|
+
return c.get?.("router.ext") ?? c.router?.ext;
|
|
236
|
+
}
|
|
237
|
+
/** 读取扩展存储服务——插件用它读开关、读写自己存的数据。 */
|
|
238
|
+
function currentExtStore(ctx) {
|
|
239
|
+
const c = ctx;
|
|
240
|
+
return c.get?.("router.extStore") ?? c.router?.extStore;
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/index.ts
|
|
244
|
+
const name = "dsh-router-ext-rtk";
|
|
245
|
+
function apply(ctx) {
|
|
246
|
+
ctx.inject(["router.ext"], (sctx) => {
|
|
247
|
+
const exts = currentExts(sctx);
|
|
248
|
+
if (!exts) return void 0;
|
|
249
|
+
if (exts.rtk) return void 0;
|
|
250
|
+
const ext = createRtkExt();
|
|
251
|
+
exts.rtk = ext;
|
|
252
|
+
ctx.emit("internal/service", "router.ext", exts);
|
|
253
|
+
ctx.logger?.info?.("[dsh-router-ext-rtk] registered router.ext: rtk");
|
|
254
|
+
const store = currentExtStore(sctx);
|
|
255
|
+
const unmount = mountRtkIntercept(ctx, () => store?.isEnabled("rtk") === true, () => ext.getState().ready);
|
|
256
|
+
return () => {
|
|
257
|
+
unmount();
|
|
258
|
+
ext.dispose?.();
|
|
259
|
+
delete exts.rtk;
|
|
260
|
+
resetProbe();
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
export { apply, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-router-ext-rtk",
|
|
3
|
+
"version": "0.3.12",
|
|
4
|
+
"description": "DSH plugin: RTK (Rust Token Killer) extension for dsh-router. Registers a command-rewrite ext via the router.ext cordis service so dsh-router ships every bash tool call through `rtk rewrite`, cutting 60-90% of LLM-bound bash output.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/CARVIN94/dsh-router-ext-rtk.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/index.js",
|
|
13
|
+
"./src/*": "./src/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"src",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf lib && tsdown",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "node --test \"src/**/*.test.ts\""
|
|
38
|
+
},
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"cordis": "^4.0.0-rc.7"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"cordis": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.4.1",
|
|
51
|
+
"tsdown": "^0.23.0",
|
|
52
|
+
"typescript": "^7.0.2"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `router.ext` 通用契约副本 —— 与 dsh-router/src/ext/contract.ts 逐字同步。
|
|
3
|
+
*
|
|
4
|
+
* dsh-router-ext-rtk 是独立 npm 包,不能 import dsh-router 的 src(避免安装期
|
|
5
|
+
* 依赖拉取整个路由核心),所以复制这份契约。改动契约必须**两处同步**
|
|
6
|
+
* (dsh-router/src/ext/contract.ts + 本文件)——鸭子类型,tsc 抓不到跨仓漂移。
|
|
7
|
+
*
|
|
8
|
+
* 分工(2026-09 重构):dsh-router 核心只做**管理面**(注册表面板 + 代存开关/数据),
|
|
9
|
+
* 拦截 `tools/execute`、裁决 enabled+ready、短路执行全部归扩展插件自己。
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
|
|
13
|
+
/** 扩展器唯一 id(注册键)。 */
|
|
14
|
+
export type ExtId = string
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
|
|
18
|
+
*
|
|
19
|
+
* 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
|
|
20
|
+
* 插件只负责做事 + 报自己是否可用。
|
|
21
|
+
*/
|
|
22
|
+
export interface ExtState {
|
|
23
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
|
|
24
|
+
ready: boolean
|
|
25
|
+
/** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
|
|
26
|
+
detail?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员。
|
|
31
|
+
* 注意**没有 `rewrite`** —— 怎么改一条命令是插件的私事,核心不感知。
|
|
32
|
+
*/
|
|
33
|
+
export interface RouterExt {
|
|
34
|
+
/** 扩展器 id,也是 `router.ext` 表里的注册键。 */
|
|
35
|
+
readonly id: ExtId
|
|
36
|
+
/** 面板显示名(如「RTK」)。 */
|
|
37
|
+
readonly name: string
|
|
38
|
+
/** 面板副标题说明。 */
|
|
39
|
+
readonly description?: string
|
|
40
|
+
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
41
|
+
getState(): ExtState
|
|
42
|
+
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
43
|
+
dispose?(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
|
|
47
|
+
export interface ExtInfo {
|
|
48
|
+
id: string
|
|
49
|
+
name: string
|
|
50
|
+
description?: string
|
|
51
|
+
/** 核心持久化的开关(默认关)。 */
|
|
52
|
+
enabled: boolean
|
|
53
|
+
/** 插件报的运行时就绪状态。 */
|
|
54
|
+
ready: boolean
|
|
55
|
+
/** 不就绪时的说明(面板红字)。 */
|
|
56
|
+
detail?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
|
|
60
|
+
export interface RouterExtService {
|
|
61
|
+
[extId: string]: RouterExt
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 扩展的存储面(核心 provide,插件 inject)。
|
|
66
|
+
*
|
|
67
|
+
* 插件**不自己 file IO**:落盘位置由核心锚定(不跟 cwd 跑),读写都经核心。
|
|
68
|
+
* 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
|
|
69
|
+
*/
|
|
70
|
+
export interface ExtStoreService {
|
|
71
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
72
|
+
isEnabled(id: string): boolean
|
|
73
|
+
/** 置开关(面板/API 调)。 */
|
|
74
|
+
setEnabled(id: string, enabled: boolean): void
|
|
75
|
+
/** 读插件自己的数据块(没有 = undefined)。 */
|
|
76
|
+
readData<T = unknown>(id: string): T | undefined
|
|
77
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
|
|
78
|
+
writeData(id: string, value: unknown): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)。 */
|
|
82
|
+
export function currentExts(ctx: Context): RouterExtService | undefined {
|
|
83
|
+
const c = ctx as unknown as {
|
|
84
|
+
get?: (key: string) => unknown
|
|
85
|
+
router?: { ext?: RouterExtService }
|
|
86
|
+
}
|
|
87
|
+
return (c.get?.('router.ext') ?? c.router?.ext) as RouterExtService | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 读取扩展存储服务——插件用它读开关、读写自己存的数据。 */
|
|
91
|
+
export function currentExtStore(ctx: Context): ExtStoreService | undefined {
|
|
92
|
+
const c = ctx as unknown as {
|
|
93
|
+
get?: (key: string) => unknown
|
|
94
|
+
router?: { extStore?: ExtStoreService }
|
|
95
|
+
}
|
|
96
|
+
return (c.get?.('router.extStore') ?? c.router?.extStore) as ExtStoreService | undefined
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-router-ext-rtk —— DSH 插件 host half。
|
|
3
|
+
*
|
|
4
|
+
* 通过 cordis service `router.ext`(聚合表 `{ [extId]: RouterExt }`)把 RTK 注册给
|
|
5
|
+
* dsh-router,让面板/设置页能看到它;并**自己**挂 `tools/execute` 拦截 bash 调用。
|
|
6
|
+
*
|
|
7
|
+
* 2026-09 重构后的分工:
|
|
8
|
+
* - dsh-router 核心 = 管理面(注册表面板 + 代存开关/数据,`router.extStore`)。
|
|
9
|
+
* - 本插件 = 执行面(自己挂 tools/execute、自己按 enabled+ready 裁决、自己短路执行)。
|
|
10
|
+
*
|
|
11
|
+
* 本插件**无状态**:开关由 dsh-router 核心持久化(`<dataDir>/ext.json`),这里经
|
|
12
|
+
* `router.extStore` 读,不自己 file IO、不存状态文件。
|
|
13
|
+
*
|
|
14
|
+
* cordis 的 `ctx.provide` 每个 service name 只允许一个插件注册,多个扩展插件不能各
|
|
15
|
+
* 自 provide `router.ext`(会抛 "service has been registered")。本插件像供应商插件
|
|
16
|
+
* 那样用共享表模式:`inject` 等待该 service(由 dsh-router 核心先 provide 空表),
|
|
17
|
+
* 把 `rtk` 追加进共享表后广播一次 `internal/service`,让 dsh-router 读到。
|
|
18
|
+
* 该模式与插件加载顺序无关。
|
|
19
|
+
*
|
|
20
|
+
* 拦截在拿到 `router.ext` 之后才挂(顺序:注册 → 拦截),所以开关/存储就绪前不会有
|
|
21
|
+
* 半开的拦截窗口。
|
|
22
|
+
*/
|
|
23
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
24
|
+
import { createRtkExt, resetProbe } from './rtk.ts'
|
|
25
|
+
import { mountRtkIntercept } from './intercept.ts'
|
|
26
|
+
import { currentExts, currentExtStore, type RouterExtService } from './contract.ts'
|
|
27
|
+
|
|
28
|
+
export const name = 'dsh-router-ext-rtk'
|
|
29
|
+
|
|
30
|
+
export function apply(ctx: Context): void {
|
|
31
|
+
// 等 router.ext 可用后,把 rtk 追加进共享聚合表并通知 dsh-router 重扫。
|
|
32
|
+
ctx.inject(['router.ext'], (sctx) => {
|
|
33
|
+
const exts = currentExts(sctx)
|
|
34
|
+
if (!exts) return undefined
|
|
35
|
+
if (exts.rtk) return undefined // 已注册
|
|
36
|
+
const ext = createRtkExt()
|
|
37
|
+
exts.rtk = ext
|
|
38
|
+
ctx.emit('internal/service', 'router.ext', exts)
|
|
39
|
+
ctx.logger?.info?.('[dsh-router-ext-rtk] registered router.ext: rtk')
|
|
40
|
+
|
|
41
|
+
// 自挂拦截:开关问核心存储,ready 问自己;裁决与短路都在 intercept.ts。
|
|
42
|
+
// ctx 是 cordis Context,但 `on('tools/execute', ...)` 的鸭子签名鸭子类型过不去
|
|
43
|
+
// (事件名是 keyof Events,字符串传不进)——与 dsh-router 核心同款 cast。
|
|
44
|
+
const store = currentExtStore(sctx)
|
|
45
|
+
const unmount = mountRtkIntercept(
|
|
46
|
+
ctx as unknown as {
|
|
47
|
+
get: (name: string) => unknown
|
|
48
|
+
on: (name: string, listener: (...args: unknown[]) => unknown) => unknown
|
|
49
|
+
},
|
|
50
|
+
() => store?.isEnabled('rtk') === true,
|
|
51
|
+
() => ext.getState().ready,
|
|
52
|
+
)
|
|
53
|
+
return () => {
|
|
54
|
+
unmount()
|
|
55
|
+
ext.dispose?.()
|
|
56
|
+
delete exts.rtk
|
|
57
|
+
resetProbe()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 类型引用确保契约副本被编译检查(避免死代码引起的不一致漂移)。
|
|
63
|
+
export type { RouterExtService }
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash 拦截测试 —— node --test 直接跑。
|
|
3
|
+
*
|
|
4
|
+
* 锁定 2026-09 重构后归本插件的裁决/短路语义:
|
|
5
|
+
* - 开关关 / rtk 不就绪 → 原样 next(),绝不改写
|
|
6
|
+
* - 只拦 bash;非 bash、command 非字符串、已 abort → 原样
|
|
7
|
+
* - 命中后用**改写后**的命令执行
|
|
8
|
+
* - `tools.get('bash', exec.agent)` 必须带 agent scope —— 不带查不到,静默原样(头号坑)
|
|
9
|
+
* - 执行抛错 → 转成 error envelope,不向外抛
|
|
10
|
+
*/
|
|
11
|
+
import test from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { mountRtkIntercept, RTK_EXT_ID, type BashExec, type Envelope } from './intercept.ts'
|
|
17
|
+
|
|
18
|
+
/** 造一个真能跑的假 rtk:`rtk rewrite <cmd>` → 输出 `RTK(<cmd>)`,退出码 0。 */
|
|
19
|
+
function fakeRtkBin(): string {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'ext-rtk-'))
|
|
21
|
+
const fp = join(dir, 'rtk')
|
|
22
|
+
writeFileSync(fp, '#!/bin/sh\necho "RTK($2)"\n')
|
|
23
|
+
chmodSync(fp, 0o755)
|
|
24
|
+
return fp
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Ctx {
|
|
28
|
+
get: (name: string) => unknown
|
|
29
|
+
on: (name: string, fn: unknown) => unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 最小 cordis-like ctx:记录 tools.get 的调用,并交出监听函数供手动触发。 */
|
|
33
|
+
function fakeCtx(opts: { bashTool?: { execute: (args: unknown, exec: unknown) => Promise<unknown> } } = {}) {
|
|
34
|
+
const gets: Array<{ name: string; scope: unknown }> = []
|
|
35
|
+
let listener: ((exec: BashExec, next: () => Promise<Envelope>) => Promise<Envelope>) | undefined
|
|
36
|
+
const ctx: Ctx = {
|
|
37
|
+
get: (name) => (name === 'tools' ? { get: (n: string, scope?: unknown) => {
|
|
38
|
+
gets.push({ name: n, scope })
|
|
39
|
+
return n === 'bash' && scope !== undefined ? opts.bashTool : undefined
|
|
40
|
+
} } : undefined),
|
|
41
|
+
on: (name, fn) => {
|
|
42
|
+
if (name === 'tools/execute') listener = fn as typeof listener
|
|
43
|
+
return () => {}
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
const call = (exec: BashExec): { res: Promise<Envelope>; nexted: () => boolean } => {
|
|
47
|
+
let nexted = false
|
|
48
|
+
const res = (listener as NonNullable<typeof listener>)(exec, async () => {
|
|
49
|
+
nexted = true
|
|
50
|
+
return { isError: false }
|
|
51
|
+
})
|
|
52
|
+
return { res, nexted: () => nexted }
|
|
53
|
+
}
|
|
54
|
+
return { ctx, gets, call }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const RTK_BIN = fakeRtkBin()
|
|
58
|
+
|
|
59
|
+
/** 挂拦截,返回 ctx 与触发入口。`bin` 缺省用一个真能改写的假 rtk。 */
|
|
60
|
+
function setup(o: { enabled?: boolean; ready?: boolean; bin?: string; execute?: (args: unknown, exec: unknown) => Promise<unknown> } = {}) {
|
|
61
|
+
const h = fakeCtx(o.execute === undefined ? {} : { bashTool: { execute: o.execute } })
|
|
62
|
+
const unmount = mountRtkIntercept(
|
|
63
|
+
h.ctx,
|
|
64
|
+
() => o.enabled !== false,
|
|
65
|
+
() => o.ready !== false,
|
|
66
|
+
o.bin ?? RTK_BIN,
|
|
67
|
+
)
|
|
68
|
+
return { ...h, unmount }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bashExec(over: Partial<BashExec> = {}): BashExec {
|
|
72
|
+
return { name: 'bash', arguments: { command: 'git status' }, signal: new AbortController().signal, agent: 'a1', ...over }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('ext id 与核心 ext.json 的键一致', () => {
|
|
76
|
+
assert.equal(RTK_EXT_ID, 'rtk')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('开关关 → 原样,不查工具', async () => {
|
|
80
|
+
const h = setup({ enabled: false, ready: true })
|
|
81
|
+
const { res, nexted } = h.call(bashExec())
|
|
82
|
+
await res
|
|
83
|
+
assert.equal(nexted(), true)
|
|
84
|
+
assert.equal(h.gets.length, 0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('rtk 不就绪 → 原样,不查工具', async () => {
|
|
88
|
+
const h = setup({ enabled: true, ready: false })
|
|
89
|
+
const { res, nexted } = h.call(bashExec())
|
|
90
|
+
await res
|
|
91
|
+
assert.equal(nexted(), true)
|
|
92
|
+
assert.equal(h.gets.length, 0)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('非 bash 工具 → 原样', async () => {
|
|
96
|
+
const h = setup()
|
|
97
|
+
const { res, nexted } = h.call(bashExec({ name: 'read' }))
|
|
98
|
+
await res
|
|
99
|
+
assert.equal(nexted(), true)
|
|
100
|
+
assert.equal(h.gets.length, 0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('command 非字符串 / 已 abort → 原样', async () => {
|
|
104
|
+
const h = setup()
|
|
105
|
+
const a = h.call(bashExec({ arguments: { command: 42 } }))
|
|
106
|
+
const aborted = new AbortController()
|
|
107
|
+
aborted.abort()
|
|
108
|
+
const b = h.call(bashExec({ signal: aborted.signal }))
|
|
109
|
+
await a.res
|
|
110
|
+
await b.res
|
|
111
|
+
assert.equal(a.nexted(), true)
|
|
112
|
+
assert.equal(b.nexted(), true)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('命中 → 用改写后的命令执行(不是原命令)', async () => {
|
|
116
|
+
const executed: Array<{ command?: unknown }> = []
|
|
117
|
+
const h = setup({ execute: async (args) => {
|
|
118
|
+
executed.push(args as { command?: unknown })
|
|
119
|
+
return 'OK'
|
|
120
|
+
} })
|
|
121
|
+
const { res, nexted } = h.call(bashExec())
|
|
122
|
+
const out = await res
|
|
123
|
+
assert.equal(nexted(), false, '命中后不应再走 next()')
|
|
124
|
+
assert.deepEqual(executed, [{ command: 'RTK(git status)' }])
|
|
125
|
+
assert.equal(out.isError, false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('tools.get 必须带 agent scope —— 不带查不到 bash,静默原样(头号坑)', async () => {
|
|
129
|
+
let ran = 0
|
|
130
|
+
const h = setup({ execute: async () => {
|
|
131
|
+
ran += 1
|
|
132
|
+
return 'OK'
|
|
133
|
+
} })
|
|
134
|
+
// 带 agent:查的是 ('bash', exec.agent)
|
|
135
|
+
await h.call(bashExec()).res
|
|
136
|
+
assert.equal(h.gets[0]?.name, 'bash')
|
|
137
|
+
assert.equal(h.gets[0]?.scope, 'a1')
|
|
138
|
+
assert.equal(ran, 1)
|
|
139
|
+
|
|
140
|
+
// 不带 agent → tools.get 返回 undefined → 原样,一次都不执行
|
|
141
|
+
const before = ran
|
|
142
|
+
const { res, nexted } = h.call(bashExec({ agent: undefined }))
|
|
143
|
+
await res
|
|
144
|
+
assert.equal(nexted(), true)
|
|
145
|
+
assert.equal(ran, before)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('无等价改写 → 原样(假 rtk 不存在)', async () => {
|
|
149
|
+
const h = setup({ bin: join(tmpdir(), 'definitely-not-rtk') })
|
|
150
|
+
const { res, nexted } = h.call(bashExec())
|
|
151
|
+
await res
|
|
152
|
+
assert.equal(nexted(), true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('执行抛错 → 转成 error envelope,不向外抛', async () => {
|
|
156
|
+
const h = setup({ execute: async () => {
|
|
157
|
+
throw new Error('boom')
|
|
158
|
+
} })
|
|
159
|
+
const out = await h.call(bashExec()).res
|
|
160
|
+
assert.equal(out.isError, true)
|
|
161
|
+
assert.match(out.error?.message ?? '', /boom/)
|
|
162
|
+
})
|
package/src/intercept.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash 调用拦截 —— ext-rtk **自己**挂 `tools/execute`、自己裁决、自己短路。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09 重构:原来这层在 dsh-router 核心(`src/ext/proxy.ts`),核心代挂、代裁决、
|
|
5
|
+
* 代短路。核心只在一个消费者时是纯亏损(共享表 + inject 广播 + 契约两份同步,只为
|
|
6
|
+
* 留 40 行委派),所以归还插件:核心退成**管理面**(注册表面板 + 代存开关/数据)。
|
|
7
|
+
*
|
|
8
|
+
* 挂点语义:`tools/execute` 是 around-dispatch waterfall。我们不调 `next()` 短路整条
|
|
9
|
+
* 链路。改写在 token 冻结之后、工具 body 执行之前发生:sandbox 审批 / guard 等前置
|
|
10
|
+
* 已在 prepare 阶段做过,我们只在**同一授权的调用**里换命令字符串,不绕过任何审批。
|
|
11
|
+
*
|
|
12
|
+
* 关键不变量:
|
|
13
|
+
* - 只拦 `name === 'bash'`。其他工具(含 run_code 体内自起的子进程读取)不动。
|
|
14
|
+
* - 本插件未开启(核心 `extStore.isEnabled('rtk')`)或 rtk 未就绪 → 原样 `next()`。
|
|
15
|
+
* - 命中后直接调 bash 工具定义的 `execute(改写后参数, exec)`,返回
|
|
16
|
+
* `{ isError:false, value }`,由 registry 用名义 arguments(原始命令)re-render。
|
|
17
|
+
* - 任何一步出问题都退回 `next()`:命令永不因本扩展而失败。
|
|
18
|
+
*
|
|
19
|
+
* **头号坑(必须守住)**:`tools.get('bash', exec.agent)` 的第二个参数(agent scope)
|
|
20
|
+
* 不能省。工具注册在 agent scope 里,只查全局视图查不到 'bash' → **静默走 next()、
|
|
21
|
+
* 从不改写、不报错**。症状极具迷惑性(开关全开、命令正常、就是不生效)。
|
|
22
|
+
*
|
|
23
|
+
* ponytail: 天花板 —— 本插件自己挂监听,意味着多扩展插件各自挂时顺序不可控。目前
|
|
24
|
+
* 只有 rtk 一家,无所谓;第二家出现时要收敛(升级路径:核心暴露顺序委派的共享工具
|
|
25
|
+
* 方法,而不是收回拦截)。
|
|
26
|
+
*/
|
|
27
|
+
import { tryRewrite } from './rtk.ts'
|
|
28
|
+
|
|
29
|
+
/** Pending-call 的最小形状(只取我们需要的字段)。 */
|
|
30
|
+
export interface BashExec {
|
|
31
|
+
name: string
|
|
32
|
+
arguments: unknown
|
|
33
|
+
signal: AbortSignal
|
|
34
|
+
/** 调用方 agent —— `tools.get()` 必须带它,见文件头头号坑。 */
|
|
35
|
+
agent?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `bash` 工具参数(只作用于 command 字段)。 */
|
|
39
|
+
interface BashArgs {
|
|
40
|
+
command?: unknown
|
|
41
|
+
[k: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** around-wrapper 返回的规范化结果(registry 能消费的形状)。 */
|
|
45
|
+
export interface Envelope {
|
|
46
|
+
isError: boolean
|
|
47
|
+
value?: unknown
|
|
48
|
+
error?: { message: string }
|
|
49
|
+
content?: Array<{ type: 'text'; text: string }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ToolRuntime 的最小可见面。get 的第二个参数是 scope(agent)。 */
|
|
53
|
+
interface ToolRuntimeFace {
|
|
54
|
+
get(name: string, scope?: unknown): { execute: (args: unknown, exec: unknown) => Promise<unknown> } | undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** ext id —— 与核心 ext.json 里的键、面板注册键一致。 */
|
|
58
|
+
export const RTK_EXT_ID = 'rtk'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 挂 bash 拦截。返回清理函数(cordis 的 on 监听随 fiber 卸载自动注销,返回值供显式组合)。
|
|
62
|
+
*
|
|
63
|
+
* @param ctx cordis context(`get('tools')` 懒查工具服务,与加载顺序解耦)
|
|
64
|
+
* @param isEnabled 读核心存的开关(本插件不存开关)
|
|
65
|
+
* @param isReady 本插件自报的运行时就绪
|
|
66
|
+
*/
|
|
67
|
+
export function mountRtkIntercept(
|
|
68
|
+
ctx: {
|
|
69
|
+
get: (name: string) => unknown
|
|
70
|
+
on: (name: string, listener: (...args: unknown[]) => unknown) => unknown
|
|
71
|
+
},
|
|
72
|
+
isEnabled: () => boolean,
|
|
73
|
+
isReady: () => boolean,
|
|
74
|
+
envBin?: string,
|
|
75
|
+
): () => void {
|
|
76
|
+
const toolsOf = (): ToolRuntimeFace | undefined => {
|
|
77
|
+
const t = ctx.get('tools')
|
|
78
|
+
return t && typeof (t as { get?: unknown }).get === 'function' ? (t as ToolRuntimeFace) : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const listener = async (exec: BashExec, next: () => Promise<Envelope>): Promise<Envelope> => {
|
|
82
|
+
if (exec.name !== 'bash' || exec.signal.aborted) return next()
|
|
83
|
+
const args = exec.arguments as BashArgs
|
|
84
|
+
const cmd = args?.command
|
|
85
|
+
if (typeof cmd !== 'string' || cmd.trim() === '') return next()
|
|
86
|
+
|
|
87
|
+
// 裁决归本插件:开关(核心存的) + 就绪(自己报的),任一不满足 → 原样。
|
|
88
|
+
if (!isEnabled()) return next()
|
|
89
|
+
if (!isReady()) return next()
|
|
90
|
+
|
|
91
|
+
const rewritten = tryRewrite(cmd, envBin).rewritten
|
|
92
|
+
if (rewritten === null) return next()
|
|
93
|
+
|
|
94
|
+
const tools = toolsOf()
|
|
95
|
+
if (!tools) return next()
|
|
96
|
+
// 必须带 agent scope —— 见文件头头号坑。
|
|
97
|
+
const bashTool = tools.get('bash', exec.agent)
|
|
98
|
+
if (!bashTool) return next()
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const value = await bashTool.execute({ ...args, command: rewritten }, exec)
|
|
102
|
+
return { isError: false, value } as Envelope
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
105
|
+
return {
|
|
106
|
+
isError: true,
|
|
107
|
+
error: { message },
|
|
108
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
109
|
+
} as Envelope
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ctx.on('tools/execute', listener as never)
|
|
114
|
+
return () => {}
|
|
115
|
+
}
|
package/src/rtk.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTK 扩展器测试 —— node --test 直接跑。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - rtk 缺失时 tryRewrite 无害放行(不抛、返回 rewritten:null)
|
|
6
|
+
* - getState 报运行时就绪状态(不报 enabled —— 开关归核心)
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { createRtkExt, tryRewrite } from './rtk.ts'
|
|
13
|
+
|
|
14
|
+
test('rtk 缺失时 tryRewrite 无害放行', () => {
|
|
15
|
+
const res = tryRewrite('git status', join(tmpdir(), 'definitely-not-rtk'))
|
|
16
|
+
assert.equal(res.rewritten, null)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('getState 只报运行时事实(ready/detail),不含 enabled', () => {
|
|
20
|
+
const ext = createRtkExt()
|
|
21
|
+
const st = ext.getState()
|
|
22
|
+
assert.equal(typeof st.ready, 'boolean')
|
|
23
|
+
// 开关归核心,插件不报也不存
|
|
24
|
+
assert.equal('enabled' in st, false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('扩展器身份就绪,声明里不含 rewrite(核心不感知改写)', () => {
|
|
28
|
+
const ext = createRtkExt()
|
|
29
|
+
assert.equal(ext.id, 'rtk')
|
|
30
|
+
assert.equal(ext.name, 'RTK')
|
|
31
|
+
// 契约已剔除 rewrite:改命令是本插件私事,经 intercept.ts 自己拦截时用
|
|
32
|
+
assert.equal('rewrite' in ext, false)
|
|
33
|
+
})
|
package/src/rtk.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTK (Rust Token Killer) 扩展器 —— 只负责「改写一条命令」+「报自己是否可用」。
|
|
3
|
+
*
|
|
4
|
+
* 开关状态**不归本插件管**:核心持久化(`<dataDir>/ext.json`)并代存,插件经
|
|
5
|
+
* `router.extStore` service 读,不自己 file IO、没有状态文件。
|
|
6
|
+
*
|
|
7
|
+
* 拦截/裁决/短路也不归这里 —— 见 `intercept.ts`(本插件自己挂 `tools/execute`)。
|
|
8
|
+
*
|
|
9
|
+
* 真实代理语义:对每条 bash 命令调 `rtk rewrite "<cmd>"`,按 rtk 的退出码协议
|
|
10
|
+
* 解读。注意 rtk 的权限默认是 Ask(least-privilege),几乎没有命令会稳定落到
|
|
11
|
+
* Allow(0)——常见命令(git status / ls / cat …)都走 Ask(3)。我们这里做**真实
|
|
12
|
+
* 代理**:用户已经在面板**显式开启**了开关 = 明确授权改写,所以 Ask(3) 也直接
|
|
13
|
+
* 采用 stdout,不弹任何二次确认。只有兜底命令按原文理解:无等价(1)放行、
|
|
14
|
+
* Deny(2)原样(不阻断)、spawn 失败放行。
|
|
15
|
+
*
|
|
16
|
+
* | 退出码 | 语义 | 行为 |
|
|
17
|
+
* |--------|-----------------------------------------------------------|----------------------|
|
|
18
|
+
* | 0 | Allow,有等价改写 | 采用 stdout |
|
|
19
|
+
* | 1 | 无等价,原样 | 放行 |
|
|
20
|
+
* | 2 | Deny 规则(Claude 原生 deny) | 放行(不做阻断) |
|
|
21
|
+
* | 3 | Ask,有等价改写但默认要人工确认 | **采用 stdout**——用户已授权,
|
|
22
|
+
* | | | 真实代理不弹确认框 |
|
|
23
|
+
*
|
|
24
|
+
* 状态(只报运行时事实,不持久化):
|
|
25
|
+
* - `ready`:rtk 二进制是否可用。未装 rtk 时核心会拒绝开启(面板开关禁开、
|
|
26
|
+
* API 拒 409),并在内容区红字显示 `detail`。
|
|
27
|
+
* - `detail`:不就绪时的说明(「本机未装 rtk…」)。
|
|
28
|
+
*
|
|
29
|
+
* rtk 二进制定位优先顺序:
|
|
30
|
+
* 1. `$DSH_RTK_BIN`(显式覆盖)
|
|
31
|
+
* 2. `rtk`(PATH)
|
|
32
|
+
* 3. 常见路径兜底: `~/.local/bin/rtk`、`/opt/homebrew/bin/rtk`、`/usr/local/bin/rtk`、`~/.cargo/bin/rtk`
|
|
33
|
+
*/
|
|
34
|
+
import { execFileSync } from 'node:child_process'
|
|
35
|
+
import { accessSync, constants } from 'node:fs'
|
|
36
|
+
import { join } from 'node:path'
|
|
37
|
+
import { homedir } from 'node:os'
|
|
38
|
+
import type { ExtState } from './contract.ts'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 一次改写的结果:改写后的命令,或 `{ rewritten: null }` 表示原样执行。
|
|
42
|
+
*
|
|
43
|
+
* 本插件私有(不在 `router.ext` 契约里)—— 怎么改命令是插件的实现细节,核心不感知。
|
|
44
|
+
*/
|
|
45
|
+
export type RewriteResult = { rewritten: string } | { rewritten: null }
|
|
46
|
+
|
|
47
|
+
/** rtk rewrite 退出码(与 rtk hooks/claude 一致)。 */
|
|
48
|
+
const RTK_EXIT_ALLOW = 0
|
|
49
|
+
const RTK_EXIT_NO_REWRITE = 1
|
|
50
|
+
const RTK_EXIT_DENY = 2
|
|
51
|
+
const RTK_EXIT_ASK = 3
|
|
52
|
+
|
|
53
|
+
/** 常见 rtk 安装路径(brew / quick-install ~/.local/bin / cargo)。 */
|
|
54
|
+
const FALLBACK_RTK_PATHS = [
|
|
55
|
+
'~/.local/bin/rtk',
|
|
56
|
+
'/opt/homebrew/bin/rtk',
|
|
57
|
+
'/usr/local/bin/rtk',
|
|
58
|
+
'~/.cargo/bin/rtk',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** 展开首个 `~/` 前缀。 */
|
|
62
|
+
function expandHome(p: string): string {
|
|
63
|
+
return p.startsWith('~/') || p.startsWith('~\\') ? join(homedir(), p.slice(2)) : p
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 定位 rtk 可执行文件。返回 `'rtk'`(走 PATH,交给 execFileSync 解析)、绝对路径,
|
|
68
|
+
* 或 `undefined`(找不到)。不缓存——`rewrite` 每次重查,避免「装了 rtk 后被缓存的
|
|
69
|
+
* 失败卡死」;探活缓存见 `rtkReady`。
|
|
70
|
+
*/
|
|
71
|
+
export function resolveRtkBin(envBin?: string): string | undefined {
|
|
72
|
+
const explicit = envBin !== undefined && envBin !== '' ? [envBin] : []
|
|
73
|
+
for (const raw of [...explicit, 'rtk', ...FALLBACK_RTK_PATHS]) {
|
|
74
|
+
if (raw === 'rtk') return 'rtk' // PATH 命中交给 execFileSync(找不到时 execute 抛错,由 tryRewrite 兜)
|
|
75
|
+
const p = expandHome(raw)
|
|
76
|
+
try {
|
|
77
|
+
accessSync(p, constants.X_OK)
|
|
78
|
+
return p
|
|
79
|
+
} catch {
|
|
80
|
+
// 下一个候选
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** rtk 可用性探活(缓存:成功存版本,失败存 null)。 */
|
|
87
|
+
let probe: string | null | undefined = undefined
|
|
88
|
+
|
|
89
|
+
export function resetProbe(): void {
|
|
90
|
+
probe = undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rtkReady(): boolean {
|
|
94
|
+
if (probe !== undefined) return probe !== null
|
|
95
|
+
try {
|
|
96
|
+
const out = execFileSync('rtk', ['--version'], { encoding: 'utf8', timeout: 2000 }).trim()
|
|
97
|
+
probe = out === '' ? null : out
|
|
98
|
+
} catch {
|
|
99
|
+
probe = null
|
|
100
|
+
}
|
|
101
|
+
return probe !== null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 调 `rtk rewrite` 并解释退出码。缺 rtk / spawn 失败 / 无改写 → 放行,永不抛。
|
|
106
|
+
*/
|
|
107
|
+
export function tryRewrite(command: string, bin?: string): RewriteResult {
|
|
108
|
+
let target = bin
|
|
109
|
+
if (target === undefined) {
|
|
110
|
+
target = resolveRtkBin(process.env.DSH_RTK_BIN)
|
|
111
|
+
// 探活失败(rtk 不在 PATH 且不在 FALLBACK)→ 放行
|
|
112
|
+
if (target === 'rtk') {
|
|
113
|
+
try {
|
|
114
|
+
execFileSync('rtk', ['--version'], { encoding: 'utf8', timeout: 2000 })
|
|
115
|
+
} catch {
|
|
116
|
+
return { rewritten: null }
|
|
117
|
+
}
|
|
118
|
+
} else if (target === undefined) {
|
|
119
|
+
return { rewritten: null }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let out: string | undefined
|
|
123
|
+
let code = 0
|
|
124
|
+
try {
|
|
125
|
+
out = execFileSync(target, ['rewrite', command], { encoding: 'utf8', timeout: 2000 }).trim()
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const e = err as { status?: number; stdout?: string | Buffer }
|
|
128
|
+
if (typeof e.status !== 'number') return { rewritten: null } // spawn 失败放行
|
|
129
|
+
code = e.status
|
|
130
|
+
out = (e.stdout ?? '').toString().trim()
|
|
131
|
+
if (code === RTK_EXIT_DENY) return { rewritten: null } // deny 规则:原样,不阻断
|
|
132
|
+
if (code === RTK_EXIT_NO_REWRITE) return { rewritten: null }
|
|
133
|
+
// 退出码 3(Ask):真实代理下用户已授权,采用 stdout(前提是确实现改了)。
|
|
134
|
+
if (code !== RTK_EXIT_ALLOW && code !== RTK_EXIT_ASK) return { rewritten: null }
|
|
135
|
+
if (out === '') return { rewritten: null }
|
|
136
|
+
}
|
|
137
|
+
// 退出码 0/3 + 非空输出且确实改写 → 采用;否则原样。
|
|
138
|
+
if ((code === RTK_EXIT_ALLOW || code === RTK_EXIT_ASK) && out !== '' && out !== command) {
|
|
139
|
+
return { rewritten: out }
|
|
140
|
+
}
|
|
141
|
+
return { rewritten: null }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 组装 ext-rtk 的扩展器实例(注册进 router.ext 表)。
|
|
146
|
+
*
|
|
147
|
+
* 注意:不接收 stateFile —— 开关归核心存,插件无状态(除 rtk 探活缓存)。
|
|
148
|
+
* 契约里没有 `rewrite`:核心只认声明 + getState,改命令是本插件自己拦截时用的。
|
|
149
|
+
*/
|
|
150
|
+
export function createRtkExt(envBin?: string) {
|
|
151
|
+
// 启动即自检:探测 rtk 是否可用(缓存到 probe),让核心初次 getState 就有 ready
|
|
152
|
+
// /detail,而不是等到第一次 rewrite 才懒查。用户后续装好 rtk,重启插件后恢复。
|
|
153
|
+
rtkReady()
|
|
154
|
+
return {
|
|
155
|
+
id: 'rtk',
|
|
156
|
+
name: 'RTK',
|
|
157
|
+
description: 'bash 命令输出压缩,削减发给 LLM 的 60–90% bash 输出(需本机装 rtk)',
|
|
158
|
+
getState: (): ExtState => {
|
|
159
|
+
const ready = rtkReady()
|
|
160
|
+
return {
|
|
161
|
+
ready,
|
|
162
|
+
detail: ready
|
|
163
|
+
? undefined
|
|
164
|
+
: '本机未装 rtk:brew install rtk 或 curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh',
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
dispose: (): void => {
|
|
168
|
+
resetProbe()
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|