@epoch-agent/infra 0.1.0
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 +219 -0
- package/README.md +99 -0
- package/dist/index.d.ts +2468 -0
- package/dist/index.js +3099 -0
- package/dist/locales/en.yaml +2757 -0
- package/dist/locales/zh.yaml +2932 -0
- package/package.json +51 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2468 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { SpawnOptions, ChildProcess } from 'node:child_process';
|
|
3
|
+
import { ZodType } from 'zod';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 路径真源 —— 所有 ~/.epoch 下的路径只在这里算一次。
|
|
7
|
+
*
|
|
8
|
+
* 为什么需要这个文件:同一份「家目录在哪」的逻辑原来散在四处,且**行为不一致**:
|
|
9
|
+
* - core/config/loader.ts 认 EPOCH_HOME 和 EPOCH_PROFILE ✅
|
|
10
|
+
* - cli/index.ts 硬编码 join(homedir(), '.epoch') ❌
|
|
11
|
+
* - plugin-mcp/registry.ts 硬编码 ~/.epoch/mcp.json ❌
|
|
12
|
+
* - core/session/db.ts 硬编码 ~/.epoch/sessions.db ❌
|
|
13
|
+
* 于是设了 EPOCH_PROFILE=work 之后,`epoch config set` 写到 ~/.epoch/config.yaml,
|
|
14
|
+
* 而引擎去 ~/.epoch/profiles/work/config.yaml 读 —— 设置静默失效。
|
|
15
|
+
*
|
|
16
|
+
* 这里所有函数都接受可选的 homeDir 覆盖,方便测试和多 profile 并存。
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* 家目录解析优先级:
|
|
20
|
+
* 1. EPOCH_HOME(显式指定,profile 也不再生效)
|
|
21
|
+
* 2. EPOCH_PROFILE != default → ~/.epoch/profiles/<name>
|
|
22
|
+
* 3. ~/.epoch
|
|
23
|
+
*/
|
|
24
|
+
declare function resolveHomeDir(): string;
|
|
25
|
+
/** 当前 profile 名(default 表示未启用 profile) */
|
|
26
|
+
declare function resolveProfile(): string;
|
|
27
|
+
declare function configPath(homeDir?: string): string;
|
|
28
|
+
declare function envPath(homeDir?: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* 主数据库路径。会话 / 记忆索引 / TODO 都在这一个文件里
|
|
31
|
+
* (历史原因文件名叫 sessions.db)。
|
|
32
|
+
*/
|
|
33
|
+
declare function dbPath(homeDir?: string): string;
|
|
34
|
+
declare function memoriesDir(homeDir?: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* 用户级技能目录(`~/.epoch/skills/<分类>/<技能名>/SKILL.md`)。
|
|
37
|
+
*
|
|
38
|
+
* 项目级那份见下面的 `projectSkillsDir`。**写操作一律落在这里** ——
|
|
39
|
+
* 技能是可以被 `SkillLearner` 自动创建 / 改写的,让它去动用户仓库里的文件
|
|
40
|
+
* 是很重的副作用(同「不做自动 commit」那条判据)。
|
|
41
|
+
*/
|
|
42
|
+
declare function skillsDir(homeDir?: string): string;
|
|
43
|
+
declare function policiesDir(homeDir?: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* 用户级 hook 配置(方案 23 §2.6)。`~/.epoch/hooks.json`。
|
|
46
|
+
*
|
|
47
|
+
* 从 `config.yaml` 的 `hooks` 字段搬出来的:那里它是个 `z.record(z.unknown())`
|
|
48
|
+
* 的大 blob,配置层实际上没在校验它;而项目级 hook 还需要独立的信任判定粒度,
|
|
49
|
+
* 塞在一份「用户级配置文件」里没法表达。旧字段仍然读得进来(迁移路径见
|
|
50
|
+
* `core/hook/sources.ts`),但那是兼容层,不是第二个真源。
|
|
51
|
+
*/
|
|
52
|
+
declare function hooksPath(homeDir?: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* 用户级键位配置(方案 31)。`~/.epoch/keybindings.json`。
|
|
55
|
+
*
|
|
56
|
+
* **只有用户级,没有项目级**,这一条是方案 §2.2 自己定的取舍:键位是个人偏好,
|
|
57
|
+
* 而「项目能改键位」等于让一个 clone 下来的仓库改掉你的 Esc —— 风险收益不匹配。
|
|
58
|
+
* 所以这里没有对应的 `projectKeybindingsPath()`,别顺手加一个。
|
|
59
|
+
*
|
|
60
|
+
* 独立文件而不是 `config.yaml` 的一个段:这份表会长(一个动作可以绑多个键,
|
|
61
|
+
* 往后还有 vim 的一批),塞进 config.yaml 会把那份「一眼能读完」的配置顶掉;
|
|
62
|
+
* 而且 JSON 比 YAML 更适合让编辑器给它接 schema 校验。同 `hooks.json` 那条判断。
|
|
63
|
+
*/
|
|
64
|
+
declare function keybindingsPath(homeDir?: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* 用户级 Agent 角色定义目录(方案 18)。
|
|
67
|
+
*
|
|
68
|
+
* 项目级那份不在这里 —— 它是 `<项目根>/.epoch/agents`,跟着工作目录走、
|
|
69
|
+
* 且要过工作区信任闸门,由 `core/agent-role/loader.ts` 自己拼。
|
|
70
|
+
*/
|
|
71
|
+
declare function agentsDir(homeDir?: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* 用户级自定义斜杠命令目录(方案 23)。`~/.epoch/commands/**\/*.md`。
|
|
74
|
+
*
|
|
75
|
+
* 和 `agentsDir` 一样只给用户级那一份;项目级是 `<项目根>/.epoch/commands`,
|
|
76
|
+
* 见下面的 `projectCommandsDir`。**递归**枚举(子目录用来分组,
|
|
77
|
+
* `commands/git/commit.md` → `/git:commit`),这一点和 agents 不同。
|
|
78
|
+
*/
|
|
79
|
+
declare function commandsDir(homeDir?: string): string;
|
|
80
|
+
declare function mcpConfigPath(homeDir?: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* 已安装插件的根目录(方案 32)。`~/.epoch/plugins/<插件名>/`。
|
|
83
|
+
*
|
|
84
|
+
* 每个插件一个子目录,本地开发装的是**软链**、其余来源是真实拷贝 ——
|
|
85
|
+
* 两者在这一层看不出区别,判据在 `plugins.json` 的 `linked` 字段上。
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ **插件目录不在项目里,所以不过工作区信任闸门**:它是用户在自己机器上
|
|
88
|
+
* 敲 `epoch plugin install` 装进来的,和 `~/.epoch/commands` 同一个信任模型。
|
|
89
|
+
* 装之前那次确认(`epoch plugin install` 的预览 + 询问)才是这条路上的闸门。
|
|
90
|
+
*/
|
|
91
|
+
declare function pluginsDir(homeDir?: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* 插件安装记录(方案 32)。`~/.epoch/plugins.json`。
|
|
94
|
+
*
|
|
95
|
+
* 和上面那个目录分开存,是因为它们回答的是两个问题:目录回答「文件在哪」,
|
|
96
|
+
* 这份记录回答「装过什么、从哪来的、还开着没有」。把记录塞进各插件目录里
|
|
97
|
+
* (每个插件一个 `.installed.json`)会让 `epoch plugin list` 变成一次目录遍历,
|
|
98
|
+
* 而且**停用一个插件**就得去改插件自己的文件 —— 那是它的作者说了算的地方。
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ 这个文件会被并发写(两个项目里同时 `epoch plugin install`),
|
|
101
|
+
* 写入必须走 `PluginStore` 的锁 + 原子替换,别直接 `writeFileSync`。
|
|
102
|
+
*/
|
|
103
|
+
declare function pluginsStatePath(homeDir?: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* 已添加的插件市场(方案 32)。`~/.epoch/marketplaces.json`。
|
|
106
|
+
*
|
|
107
|
+
* 与 `plugins.json` 分开:市场是**插件的来源目录**,添加一个市场不装任何东西。
|
|
108
|
+
* 合成一个文件的话,「清空已装插件」和「忘掉所有市场」就绑死成了一个动作。
|
|
109
|
+
*/
|
|
110
|
+
declare function marketplacesPath(homeDir?: string): string;
|
|
111
|
+
declare function approvalsPath(homeDir?: string): string;
|
|
112
|
+
/** 工作区信任记录(哪些目录被用户明确信任 / 拒绝) */
|
|
113
|
+
declare function trustPath(homeDir?: string): string;
|
|
114
|
+
/**
|
|
115
|
+
* 项目外 `@import` 的放行记录(方案 33)。
|
|
116
|
+
*
|
|
117
|
+
* 和上面的 `trustPath()` 是两份而不是一份:它记的是「目录」,这份记的是「文件」,
|
|
118
|
+
* 判定粒度不同(前者有 `directory-tree` 作用域,后者刻意只做精确匹配)。
|
|
119
|
+
* 塞进同一个文件就得给记录加类型标签,而两边的读写时机也不一样 ——
|
|
120
|
+
* 信任在首次进入时问一次,import 放行是每遇到一个新路径问一次。
|
|
121
|
+
*/
|
|
122
|
+
declare function trustedImportsPath(homeDir?: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* 「已知工作区」最近使用清单(决定 18 第 5 条)。
|
|
125
|
+
*
|
|
126
|
+
* **本机文件,不是服务。** 判据是[方案 42 §三](../../../docs/verify/VERIFY_RECORD-42-web-ui-workbuddy.md)
|
|
127
|
+
* 那条范围闸门:「这个功能要不要一台我们运维的服务器 → 要,就不做」。
|
|
128
|
+
* 一份跨设备同步的工作区列表要账号体系和一台服务器,所以它落在 `~/.epoch/` 下,
|
|
129
|
+
* 和 `trusted-folders.json` 同一个信任模型(用户自己机器上的文件)。
|
|
130
|
+
*
|
|
131
|
+
* 和 `trustPath()` 分开是因为两者答的不是同一个问题:信任记录答「这个目录里的
|
|
132
|
+
* 文件能不能影响我的指令」,这份只答「我最近在哪几个目录里干过活」——
|
|
133
|
+
* 一个是安全判定,一个是便利性。合成一份的话,「忘掉这个最近使用项」
|
|
134
|
+
* 就会顺手撤掉一次信任决定,而用户并没有被问过那件事。
|
|
135
|
+
*/
|
|
136
|
+
declare function workspacesPath(homeDir?: string): string;
|
|
137
|
+
/** 预算累计状态(已花掉多少钱 / token) */
|
|
138
|
+
declare function budgetStatePath(homeDir?: string): string;
|
|
139
|
+
/** MCP OAuth token 存储 */
|
|
140
|
+
declare function mcpAuthPath(homeDir?: string): string;
|
|
141
|
+
/**
|
|
142
|
+
* MCP server 工具 schema 的磁盘缓存(方案 05)。`~/.epoch/cache/mcp-schema-cache.json`。
|
|
143
|
+
*
|
|
144
|
+
* 收进来是补一笔旧账:方案 05 落地时 `paths.ts` 在它的「禁止触碰」里,于是
|
|
145
|
+
* `plugin-mcp` 只好自己 `join(homeDir, 'cache', 'mcp-schema-cache.json')`,
|
|
146
|
+
* 并在方案里留了一句「建议后续把它收进 paths.ts,跟其余路径一个口径」。
|
|
147
|
+
* 那正是本文件头列的那种情况 —— 同一份「家目录在哪」的逻辑散在多处,
|
|
148
|
+
* 而散出去的那几处历史上**行为并不一致**。
|
|
149
|
+
*
|
|
150
|
+
* 这是 `~/.epoch` 下第一个 `cache/` 子目录。往后再有「丢了也不影响正确性」的
|
|
151
|
+
* 派生数据都该进这里,而不是各自在根下开一个文件 —— 有了这一层,
|
|
152
|
+
* 「清缓存」将来才可能是一句 `rm -rf ~/.epoch/cache`。
|
|
153
|
+
*/
|
|
154
|
+
declare function mcpSchemaCachePath(homeDir?: string): string;
|
|
155
|
+
/**
|
|
156
|
+
* 工具产物目录(截图 / 音频 / 下载的文件)。
|
|
157
|
+
*
|
|
158
|
+
* 大的二进制既不进上下文也不进 SQLite,落在这里,DB 和上下文里只留引用。
|
|
159
|
+
*
|
|
160
|
+
* `sessionId` 会被规范化再拼路径,理由见下面的 `bySession`。
|
|
161
|
+
*/
|
|
162
|
+
declare function artifactsDir(sessionId?: string, homeDir?: string): string;
|
|
163
|
+
/**
|
|
164
|
+
* 检查点目录(方案 27)。`~/.epoch/checkpoints/<sessionId>/<turnIndex>/`。
|
|
165
|
+
*
|
|
166
|
+
* 每轮一个子目录,里面是 `manifest.json` 加按内容哈希去重的 `blobs/<hash>` ——
|
|
167
|
+
* 也就是「这一轮动手之前,被碰过的文件长什么样」。
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ **blob 不进 SQLite**。`sessions.db` 上还挂着 FTS5 索引,把文件旧内容塞进去
|
|
170
|
+
* 会让它以每次编辑几十 KB 的速度膨胀。manifest 虽然是小 JSON,也一起放文件系统:
|
|
171
|
+
* 分到两个存储上就多一份「blob 写了、manifest 没写」的一致性要处理。
|
|
172
|
+
*
|
|
173
|
+
* `sessionId` 的清洗理由同 `artifactsDir`。
|
|
174
|
+
*/
|
|
175
|
+
declare function checkpointsDir(sessionId?: string, homeDir?: string): string;
|
|
176
|
+
/**
|
|
177
|
+
* `--worktree` 建出来的 git worktree 放在哪(方案 29 §2.5)。
|
|
178
|
+
* `~/.epoch/worktrees/<仓库名>-<短 id>/`。
|
|
179
|
+
*
|
|
180
|
+
* **不放在仓库旁边**(`../repo-epoch-xxx`):那会落进用户自己的目录树里,
|
|
181
|
+
* 有的项目根就是 home,随后 `git status` 里冒出一堆陌生目录。放在 `~/.epoch`
|
|
182
|
+
* 下的另一个好处是「清干净」有唯一答案 —— 和 artifacts / checkpoints 同一条规矩。
|
|
183
|
+
*
|
|
184
|
+
* 只给目录,不管里面叫什么:具体名字由 `cli/src/worktree.ts` 拼,
|
|
185
|
+
* 因为那要知道仓库名和这次的短 id。
|
|
186
|
+
*/
|
|
187
|
+
declare function worktreesDir(homeDir?: string): string;
|
|
188
|
+
/**
|
|
189
|
+
* 定时任务的地盘(方案 45)。`~/.epoch/automation/`。
|
|
190
|
+
*
|
|
191
|
+
* 下面有两类东西:每个任务一个**默认工作目录**(用户没填工作区时跑在那儿),
|
|
192
|
+
* 以及 `logs/<taskId>/` 下的录像与 launchd 的启动日志。
|
|
193
|
+
*
|
|
194
|
+
* 为什么默认工作目录不是 `process.cwd()`:OS 调度器起进程时的 cwd 不由我们决定
|
|
195
|
+
* (Windows 上是 `%SystemRoot%\System32`)。「工作区可选」在语义上必须落到一个
|
|
196
|
+
* 确定的目录,而不是「碰巧是哪儿就是哪儿」。
|
|
197
|
+
*/
|
|
198
|
+
declare function automationDir(homeDir?: string): string;
|
|
199
|
+
/**
|
|
200
|
+
* 某个任务的默认工作目录。`~/.epoch/automation/<taskId>/`。
|
|
201
|
+
*
|
|
202
|
+
* `taskId` 的清洗理由同 `bySession`:它虽然是我们自己生成的,但
|
|
203
|
+
* `epoch schedule fire <id>` 的 id 是命令行上来的。
|
|
204
|
+
*/
|
|
205
|
+
declare function automationWorkDir(taskId: string, homeDir?: string): string;
|
|
206
|
+
/**
|
|
207
|
+
* 某个任务的日志目录。`~/.epoch/automation/logs/<taskId>/`。
|
|
208
|
+
*
|
|
209
|
+
* 里面是 `<runId>.ndjson` 录像,以及 macOS 上 launchd 自己的
|
|
210
|
+
* `launchd.log`(进程起不来时的唯一线索 —— 我们的日志要等进程起来之后才有)。
|
|
211
|
+
*/
|
|
212
|
+
declare function automationLogsDir(taskId: string, homeDir?: string): string;
|
|
213
|
+
/** 项目级 epoch 目录名。`<项目根>/.epoch/`,与用户级的 `~/.epoch/` 同名但不同源 */
|
|
214
|
+
declare const PROJECT_DIR_NAME = ".epoch";
|
|
215
|
+
/** 项目级设置,**提交进 git 的那份**(方案 22 第 3 层) */
|
|
216
|
+
declare function projectSettingsPath(projectRoot: string): string;
|
|
217
|
+
/** 项目本地设置,**该 .gitignore 掉的那份**(方案 22 第 4 层,个人覆盖) */
|
|
218
|
+
declare function projectLocalSettingsPath(projectRoot: string): string;
|
|
219
|
+
/**
|
|
220
|
+
* 设置文件的 JSON Schema 落盘目录(`epoch config schema` 写出来的)。
|
|
221
|
+
*
|
|
222
|
+
* **和 settings.json 同一层,是为了那行 `$schema` 能写成相对路径**
|
|
223
|
+
* (`./schemas/settings.schema.json`)—— 相对路径是唯一一种能提交进仓库、
|
|
224
|
+
* 团队里每个人拉下来都成立的写法。绝对路径在别人机器上是错的,
|
|
225
|
+
* http 地址要求这个项目的源码公开可达,而它今天不是。
|
|
226
|
+
*
|
|
227
|
+
* ⚠️ **这个目录里的东西是产物,不是用户写的**:`epoch config schema` 每次都覆盖。
|
|
228
|
+
* 提不提交进 git 由用户定(提交 = 队友不用各自跑一次;不提交 = 少一份会过期的产物)。
|
|
229
|
+
*/
|
|
230
|
+
declare function projectSchemasDir(projectRoot: string): string;
|
|
231
|
+
/** 项目级自定义斜杠命令目录(方案 23)。同名时压过用户级那份,且**要过信任闸门** */
|
|
232
|
+
declare function projectCommandsDir(projectRoot: string): string;
|
|
233
|
+
/**
|
|
234
|
+
* 项目级技能目录(方案 23 §2.5)。同名时压过用户级那份,且**要过信任闸门**。
|
|
235
|
+
*
|
|
236
|
+
* 与命令目录的区别只有一个:技能是**只读**的。写操作(`SkillLearner` 的自动
|
|
237
|
+
* 创建、`skill_manager` 工具的编辑)永远落在 `skillsDir()`,不碰用户的仓库。
|
|
238
|
+
*/
|
|
239
|
+
declare function projectSkillsDir(projectRoot: string): string;
|
|
240
|
+
/**
|
|
241
|
+
* 项目级 hook 配置(方案 23 §2.6)。`<项目根>/.epoch/hooks.json`。
|
|
242
|
+
*
|
|
243
|
+
* ⚠️ 它的闸门**比别的项目级来源严一档**:未信任和未决定一律不加载,
|
|
244
|
+
* 没有方案 22 里「deny 照样生效」那种不对称 —— 命令 / 技能 / 指令文件的风险是
|
|
245
|
+
* 「往上下文里注入一段文本」,而 hook 是 spawn 子进程,clone 完还没看过的仓库
|
|
246
|
+
* 里一行 `command` 就是任意代码执行。判定在 `core/hook/sources.ts`。
|
|
247
|
+
*/
|
|
248
|
+
declare function projectHooksPath(projectRoot: string): string;
|
|
249
|
+
/**
|
|
250
|
+
* 项目级策略规则目录(方案 23 §2.6)。`<项目根>/.epoch/policies/*.toml`。
|
|
251
|
+
*
|
|
252
|
+
* 和项目级 hook 同一档闸门,理由也一样:策略规则不进上下文,但直接改变
|
|
253
|
+
* 「哪些操作不用问就放行」。判定在 `core/policy/sources.ts`。
|
|
254
|
+
*/
|
|
255
|
+
declare function projectPoliciesDir(projectRoot: string): string;
|
|
256
|
+
/**
|
|
257
|
+
* 企业托管设置(方案 22 §2.6 的第 ∞ 层)。**由 IT 管理员分发,用户不该能改**。
|
|
258
|
+
*
|
|
259
|
+
* ```
|
|
260
|
+
* Windows %ProgramData%\epoch\managed-settings.json (默认 C:\ProgramData)
|
|
261
|
+
* macOS /Library/Application Support/epoch/managed-settings.json
|
|
262
|
+
* Linux /etc/epoch/managed-settings.json
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* 三个位置的共同点是**普通用户没有写权限**(要管理员 / root),这正是这一层
|
|
266
|
+
* 能压过其它所有层的前提 —— 换个能被用户写的位置,整套托管策略就只是装饰。
|
|
267
|
+
*
|
|
268
|
+
* ## 刻意没有环境变量覆盖
|
|
269
|
+
*
|
|
270
|
+
* 其它路径都认 `EPOCH_HOME` / `EPOCH_PROFILE`,这一个不认,而且不能认:
|
|
271
|
+
* 一个 `EPOCH_MANAGED_SETTINGS=/tmp/empty.json` 就能把 IT 的策略整份关掉,
|
|
272
|
+
* 那这一层就白做了。测试和嵌入宿主要换路径,走**参数注入**
|
|
273
|
+
* (`resolveSettings` 的 `managedPath` / `buildRuntime` 的 `managedSettingsPath`)——
|
|
274
|
+
* 那是调用方自己的代码,本来就说了算,和「环境变量谁都能设」不是一回事。
|
|
275
|
+
*
|
|
276
|
+
* Linux 分支保留(见 CLAUDE.md「支持平台」:暂不支持 ≠ 不做)。
|
|
277
|
+
*/
|
|
278
|
+
declare function managedSettingsPath(os?: NodeJS.Platform): string;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* `<homeDir>/config.yaml` 的就地编辑。
|
|
282
|
+
*
|
|
283
|
+
* 手写正则而不是引 yaml 库:配置文件是给人读的,`js-yaml` 的 round-trip 会把
|
|
284
|
+
* 用户的注释、空行、字段顺序全冲掉。代价是只能改标量和一层嵌套——够用。
|
|
285
|
+
*
|
|
286
|
+
* 抽成独立模块是因为 `epoch config set` 和 `epoch model` 写的是**同一个文件**,
|
|
287
|
+
* 而改造前策略正好相反:前者小心翼翼地正则替换保留注释,后者用一个模板字符串
|
|
288
|
+
* 整体覆写,把用户的 permission / budget / headless / mcp / fallback 全冲掉。
|
|
289
|
+
*
|
|
290
|
+
* ## 2026-08-15:从 cli 搬到 infra,因为多了**第三个**写者
|
|
291
|
+
*
|
|
292
|
+
* 原来它住在 `cli/src/files/config-yaml.ts`(那边现在是一行 re-export,
|
|
293
|
+
* 既有调用方和用例一个字都不用改)。搬家的理由和它当初被抽出来的理由是同一条:
|
|
294
|
+
* 方案 54 §六 的 `hostPreset` 要在装配前替宿主把 `provider` / `model` 落进
|
|
295
|
+
* 这同一个文件,而 runtime 依赖不了 cli(嵌入宿主压根不装 cli)。
|
|
296
|
+
*
|
|
297
|
+
* 留在那边的唯一出路是复制一份正则 —— 于是「同一个文件的读写策略只能有一份」
|
|
298
|
+
* 这句话在第三个写者出现的当天就破了。
|
|
299
|
+
*/
|
|
300
|
+
/** 从零初始化时的最小配置 */
|
|
301
|
+
declare const DEFAULT_YAML = "provider:\n type: openai\n apiKey: ''\nmodel: gpt-4o-mini\nmaxTurns: 50\n";
|
|
302
|
+
/** 就地替换一个顶层标量字段,保留文件里的其他内容和注释 */
|
|
303
|
+
declare function setScalar(yaml: string, key: string, value: string): string;
|
|
304
|
+
/**
|
|
305
|
+
* 就地设置 `<section>:` 段下的一个字段。
|
|
306
|
+
*
|
|
307
|
+
* 段不存在就在文件末尾追加一个新段;段内已有该字段就替换,否则插在段首。
|
|
308
|
+
*/
|
|
309
|
+
declare function setSectionField(yaml: string, section: string, field: string, value: string): string;
|
|
310
|
+
/**
|
|
311
|
+
* 读一个 key 的值:顶层标量或 `<段>.<字段>`;没配返回 undefined。
|
|
312
|
+
*
|
|
313
|
+
* 本来长在 [cli 的 commands/config.ts](../../cli/src/commands/config.ts) 里,搬过来是因为
|
|
314
|
+
* `epoch model` 也要读同一个文件(把「当前已配置的那个」在列表里标出来并
|
|
315
|
+
* 预选中)—— 而这个模块存在的理由正是「同一个文件的读写策略只能有一份」。
|
|
316
|
+
* 留在那边就会长出第二个读法,跟本文件开头记的那个教训是同一种。
|
|
317
|
+
*
|
|
318
|
+
* 不走 `loadConfig()` 是刻意的:那边会把 schema 默认值填上(没配 model 也会
|
|
319
|
+
* 拿到 `gpt-4o-mini`),于是向导里会冒出一个用户从没选过的「当前」。
|
|
320
|
+
* 这里要回答的是「用户自己写了什么」,只能读原文。
|
|
321
|
+
*/
|
|
322
|
+
declare function readKey(yaml: string, key: string): string | undefined;
|
|
323
|
+
/**
|
|
324
|
+
* 删掉一个顶层标量字段;不存在就原样返回。
|
|
325
|
+
*
|
|
326
|
+
* 给 `epoch config unset` 用 —— 删掉之后该字段回落到 schema 默认值,
|
|
327
|
+
* 这跟「设成空字符串」不是一回事(后者会真的用空值去建 provider)。
|
|
328
|
+
*/
|
|
329
|
+
declare function unsetScalar(yaml: string, key: string): string;
|
|
330
|
+
/**
|
|
331
|
+
* 删掉某段下的一个字段;**段被删空时连段头一起删**。
|
|
332
|
+
*
|
|
333
|
+
* 后半句不是洁癖:YAML 里一个没有子项的 `models:` 解析出来是 `null`,而
|
|
334
|
+
* schema 里这些段是 `z.object({...}).optional()` —— `null` 过不了,
|
|
335
|
+
* `parseLenient` 于是把**整份配置**退回默认值(provider / model / permission /
|
|
336
|
+
* budget 全没了),只在 doctor 里留一行 `expected object, received null`。
|
|
337
|
+
* `models` 段只有一个字段,所以 `unset models.utility` 每次都会踩到;
|
|
338
|
+
* `budget` 删到最后一个字段时同样会踩。
|
|
339
|
+
*
|
|
340
|
+
* 手写的空段(比如段里只剩一行注释)这里删不掉,那一层由
|
|
341
|
+
* [core/config/loader.ts](../../core/src/config/loader.ts) 的
|
|
342
|
+
* `dropEmptySections` 兜住 —— 两层都要有:这里保证我们自己不写出坏文件,
|
|
343
|
+
* 那里保证别人写出来了也不至于让整份配置失效。
|
|
344
|
+
*/
|
|
345
|
+
declare function unsetSectionField(yaml: string, section: string, field: string): string;
|
|
346
|
+
/** 读配置文件;不存在时返回默认模板而不是空串(保证后续 setScalar 有落点) */
|
|
347
|
+
declare function readConfigYaml(path: string): string;
|
|
348
|
+
declare function writeConfigYaml(path: string, yaml: string): void;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* artifact 落盘原语 —— 「把一坨字节安全地写进 artifact 目录」全仓只做一次。
|
|
352
|
+
*
|
|
353
|
+
* 目录本身是 [paths.ts](./paths.ts) 的 `artifactsDir(sessionId)`
|
|
354
|
+
* (`~/.epoch/artifacts/<sessionId>/`)。这里只管**怎么写**,不管写哪、写什么 ——
|
|
355
|
+
* 目录由调用方给,文案由调用方拼(工具输出的措辞是产出它那一侧的事,见方案 47 §二)。
|
|
356
|
+
*
|
|
357
|
+
* ## 为什么这一层要独立存在
|
|
358
|
+
*
|
|
359
|
+
* 落盘原来只有多模态那一条路(`core/context/artifact-gate.ts` 的闸门 3),
|
|
360
|
+
* 而方案 47 把文本大输出也接了进来 —— 而文本那条路的消费者是
|
|
361
|
+
* `plugin-terminal`,它**只依赖 protocol + infra**,够不着 core。
|
|
362
|
+
* 两边各写一份 `writeFileSync` 的下场是安全要求只落实在其中一份上,
|
|
363
|
+
* 所以判据收在这里,两边都调它。
|
|
364
|
+
*
|
|
365
|
+
* ## 三条硬要求,各修一个具体的漏洞(方案 47 §1.2,抄 dsh spill)
|
|
366
|
+
*
|
|
367
|
+
* | 要求 | 修什么 |
|
|
368
|
+
* | --------------------------- | ---------------------------------------------------------- |
|
|
369
|
+
* | 私有目录 `0700` | artifact 里会有 `env`、日志、`git log`,不能世界可读 |
|
|
370
|
+
* | 文件 `0600` | 同上,且默认 umask 下 `writeFileSync` 给的是 `0644` |
|
|
371
|
+
* | 独占写 `open(path, 'wx')` | 见下面那一整段 —— 这条是唯一一条**对抗性**的 |
|
|
372
|
+
*
|
|
373
|
+
* ### `wx` 挡的是什么:预埋 symlink 把写入重定向出去
|
|
374
|
+
*
|
|
375
|
+
* `writeFileSync(path, bytes)` 会**跟随 symlink**。也就是说,谁能在 artifact
|
|
376
|
+
* 目录里预先放一个 `xxx.txt -> ~/.ssh/id_rsa`,我们下一次落盘就替他把那个文件
|
|
377
|
+
* 覆盖掉 —— 一次「保存命令输出」变成一次任意文件写。
|
|
378
|
+
*
|
|
379
|
+
* `'wx'` 是 `O_CREAT | O_EXCL | O_WRONLY`,而 **`O_EXCL` 在目标是 symlink 时
|
|
380
|
+
* 一律失败**(`EEXIST`),连悬空 symlink 都不例外 —— 这是 POSIX 明文规定的,
|
|
381
|
+
* 也正是它能当安全闸门用的原因:它不是「先检查再写」(那有 TOCTOU 窗口),
|
|
382
|
+
* 是内核在同一次 `open` 里原子地拒绝。
|
|
383
|
+
*
|
|
384
|
+
* 所以撞上 `EEXIST` 时**永远不要退回普通写**,只能换个名字重来
|
|
385
|
+
* (见 {@link resolveExisting})。
|
|
386
|
+
*
|
|
387
|
+
* ## 平台差异:Windows 上 mode 基本是装饰
|
|
388
|
+
*
|
|
389
|
+
* Node 在 Windows 上只把 mode 的「写位」映射成只读属性,`0700` / `0600` 里的
|
|
390
|
+
* 组和其他位没有对应物。**但 `wx` 那条是真的** —— `O_EXCL` 走的是
|
|
391
|
+
* `CREATE_NEW`,同样拒绝已存在的项。也就是说安全性最关键的那条两个平台都成立,
|
|
392
|
+
* 退化的只有「别人能不能读到」,而 Windows 的用户目录默认本来就不是世界可读的。
|
|
393
|
+
*/
|
|
394
|
+
/** 已存在同名文件时怎么办 */
|
|
395
|
+
type OnExisting =
|
|
396
|
+
/**
|
|
397
|
+
* 复用它。**只在按内容哈希命名时才是对的** —— 那时同名等于同内容,
|
|
398
|
+
* 复用就是去重(多模态那条路一直靠这个:同一张图重复返回不堆副本)。
|
|
399
|
+
*
|
|
400
|
+
* ⚠️ 复用前会 `lstat` 确认它**真是个普通文件**:同名的要是个 symlink,
|
|
401
|
+
* 把它的路径当 locator 交出去,等于让下游顺着链接读到别处 ——
|
|
402
|
+
* 那和直接写穿过去只差一步。不是普通文件就退回 `unique` 的行为。
|
|
403
|
+
*/
|
|
404
|
+
'reuse'
|
|
405
|
+
/** 换个名字重来。内容不保证唯一时(命令输出)用这条 */
|
|
406
|
+
| 'unique';
|
|
407
|
+
/** 一个开着的 artifact 文件。写完必须 {@link ArtifactHandle.close} */
|
|
408
|
+
interface ArtifactHandle {
|
|
409
|
+
/** 落盘后的绝对路径。对模型是不透明定位符(方案 47 §二),别让它去解析 */
|
|
410
|
+
path: string;
|
|
411
|
+
/** 追加一段内容。**失败静默吞掉** —— 落盘是 best-effort,不该让工具调用失败 */
|
|
412
|
+
append(data: string | Buffer): void;
|
|
413
|
+
/** 关掉句柄。重复调用无害 */
|
|
414
|
+
close(): void;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 独占地建一个 artifact 文件并把句柄交出去。
|
|
418
|
+
*
|
|
419
|
+
* @param dir 落盘目录(绝对路径),不存在会按 `0700` 建出来
|
|
420
|
+
* @param name 文件名主干,会被 {@link sanitize} 清洗
|
|
421
|
+
* @param extension 扩展名,含点(`.txt`)
|
|
422
|
+
* @param onExisting 同名时的处理,见 {@link OnExisting}
|
|
423
|
+
* @returns 句柄;**任何一步失败都返回 `undefined` 而不是抛** ——
|
|
424
|
+
* 调用方一律按 best-effort 处理(方案 47 §2.4:不把一次成功的调用变成失败)
|
|
425
|
+
*/
|
|
426
|
+
declare function openArtifact(dir: string, name: string, extension: string, onExisting?: OnExisting): ArtifactHandle | undefined;
|
|
427
|
+
/**
|
|
428
|
+
* 一次写完就关。{@link openArtifact} 的常用形态。
|
|
429
|
+
*
|
|
430
|
+
* @returns 落盘路径;失败是 `undefined`(理由同 {@link openArtifact})
|
|
431
|
+
*/
|
|
432
|
+
declare function writeArtifact(dir: string, name: string, extension: string, data: string | Buffer, onExisting?: OnExisting): string | undefined;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 按 **Unicode 码点**取头尾 —— 「截断不许切出半个字符」全仓只实现一次。
|
|
436
|
+
*
|
|
437
|
+
* ## 为什么 `.slice()` 不够
|
|
438
|
+
*
|
|
439
|
+
* JS 的字符串索引是 **UTF-16 码元**,不是码点。基本平面之外的字符
|
|
440
|
+
* (emoji `😀` U+1F600、CJK 扩展 B 的 `𠮷` U+20BB7)在内存里是一对**代理对**,
|
|
441
|
+
* 占两个码元。`text.slice(0, 2000)` 正好落在代理对中间时,切出来的是一个
|
|
442
|
+
* 孤立的高代理 `\uD83D` —— 它不是任何字符,渲染成 `�`,写进文件是坏的 UTF-8,
|
|
443
|
+
* 喂给模型的 tokenizer 也是一段垃圾。
|
|
444
|
+
*
|
|
445
|
+
* > ⚠️ **常见的误解**:以为「中文不受影响」。绝大多数汉字确实在基本平面
|
|
446
|
+
* > (一个码元,切不坏),但 CJK 扩展 B/C/D 里的生僻字是代理对,
|
|
447
|
+
* > 而 emoji 在命令输出里到处都是(`✅` 是单码元,`🚀`/`🎉` 是代理对)。
|
|
448
|
+
* > 所以判据是「按码点切」,不是「有没有中文」。
|
|
449
|
+
*
|
|
450
|
+
* ## 为什么不用 `Array.from(text)`
|
|
451
|
+
*
|
|
452
|
+
* 那是最短的写法,但它会为一个 10 MB 的字符串建一个几百万项的数组 ——
|
|
453
|
+
* 而这两个函数的**唯一**调用场景就是大输出(命令输出落盘、工具结果裁剪)。
|
|
454
|
+
* 下面走的是原地索引扫描:O(n) 时间、O(1) 额外内存。
|
|
455
|
+
*
|
|
456
|
+
* 调用方:[plugin-terminal 的 spill.ts](../../plugins/plugin-terminal/src/spill.ts)
|
|
457
|
+
* (命令输出的头尾预览)和 [core 的 compressor.ts](../../core/src/context/compressor.ts)
|
|
458
|
+
* (工具结果的头中尾保留)。两处同一个判据,所以住在这里而不是各写一份。
|
|
459
|
+
*/
|
|
460
|
+
/** 这段文本有多少个码点。`'😀'.length` 是 2,这个函数给 1 */
|
|
461
|
+
declare function countCodePoints(text: string): number;
|
|
462
|
+
/** 取前 `count` 个码点。`count` 不小于总长时原样返回 */
|
|
463
|
+
declare function headCodePoints(text: string, count: number): string;
|
|
464
|
+
/** 取后 `count` 个码点。`count` 不小于总长时原样返回 */
|
|
465
|
+
declare function tailCodePoints(text: string, count: number): string;
|
|
466
|
+
/**
|
|
467
|
+
* 头中尾保留:留前 `head` 个和后 `tail` 个码点,中间交给 `join` 拼。
|
|
468
|
+
*
|
|
469
|
+
* **两头的和不短于原文时原样返回** —— 否则一段本来就不长的文本会被塞进一个
|
|
470
|
+
* 「省略了 0 个字符」的标记,比不裁更难读。
|
|
471
|
+
*
|
|
472
|
+
* @param join 中间那段的替代物。收到被省略的**码点数**,返回要插进去的标记
|
|
473
|
+
*/
|
|
474
|
+
declare function keepHeadAndTail(text: string, head: number, tail: number, join: (skipped: number) => string): string;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* SQLite 连接与迁移。
|
|
478
|
+
*
|
|
479
|
+
* 为什么要收口:会话、记忆索引、TODO 三个模块都用 ~/.epoch/sessions.db 这**同一个
|
|
480
|
+
* 文件**,但原来各自 `new Database(path)`、各自设 pragma、各自 CREATE TABLE
|
|
481
|
+
* IF NOT EXISTS —— 一个进程对同一文件开三个连接,没有统一的迁移版本,
|
|
482
|
+
* 而且谁先调 close() 都不影响别人(各关自己那个),schema 变更只能靠
|
|
483
|
+
* IF NOT EXISTS 兜着,没法改列。
|
|
484
|
+
*
|
|
485
|
+
* 这里做两件事:
|
|
486
|
+
* 1. 同一路径同一进程只开一个连接,引用计数管生命周期 —— 这样各模块继续
|
|
487
|
+
* 各自调 close(),最后一个关掉才真正关闭,语义不变但不再重复连接。
|
|
488
|
+
* 2. 版本化迁移:每个模块用自己的 namespace 注册一串迁移,记在
|
|
489
|
+
* _epoch_migrations 表里,按需增量执行。
|
|
490
|
+
*/
|
|
491
|
+
|
|
492
|
+
type SqliteDatabase = Database.Database;
|
|
493
|
+
/**
|
|
494
|
+
* 打开(或复用)一个数据库连接,引用计数 +1。
|
|
495
|
+
*
|
|
496
|
+
* `:memory:` 不进池:每个内存库都是独立的,共享会让测试互相污染。
|
|
497
|
+
*/
|
|
498
|
+
declare function openDatabase(path: string): SqliteDatabase;
|
|
499
|
+
/**
|
|
500
|
+
* 引用计数 -1,归零时真正关闭。
|
|
501
|
+
*
|
|
502
|
+
* 幂等:重复 release 同一路径不会把别人的连接关掉(refs 不会低于 0)。
|
|
503
|
+
*/
|
|
504
|
+
declare function releaseDatabase(path: string): void;
|
|
505
|
+
/** 当前该路径的引用数(测试用) */
|
|
506
|
+
declare function refCount(path: string): number;
|
|
507
|
+
/** 强制关闭所有连接(进程退出兜底 / 测试清理) */
|
|
508
|
+
declare function closeAllDatabases(): void;
|
|
509
|
+
interface Migration {
|
|
510
|
+
/**
|
|
511
|
+
* **稳定身份。「这条迁移跑过没有」认的是它,不是 {@link version}。**
|
|
512
|
+
*
|
|
513
|
+
* 一旦进过任何一个库就**永不修改** —— 改了等于宣布这是一条新迁移,
|
|
514
|
+
* 于是它会在所有老库上重跑一次。
|
|
515
|
+
*
|
|
516
|
+
* 为什么身份不能是版本号:版本号是个**序数**,两个分支各写一个 `version: 5`
|
|
517
|
+
* 是必然会发生的事,而合并时只能把其中一个改号。改号那一刻,所有跑过旧号的库
|
|
518
|
+
* 就都成了「记着 5、而代码里 5 是另一件事」—— 详见 {@link migrate} 的注释。
|
|
519
|
+
*/
|
|
520
|
+
id: string;
|
|
521
|
+
/** 只决定**执行顺序**。从 1 开始递增、不跳号。改号是安全的,靠 `id` 认账 */
|
|
522
|
+
version: number;
|
|
523
|
+
/** 人读的说明,出问题时靠它定位。**可以改**(身份是 `id`) */
|
|
524
|
+
description: string;
|
|
525
|
+
up: (db: SqliteDatabase) => void;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 按 namespace 增量执行迁移。
|
|
529
|
+
*
|
|
530
|
+
* namespace 让四个模块(session / memory / tracker / schedule)在同一个库里各自管
|
|
531
|
+
* 自己的版本号,互不干扰。每条迁移在一个事务里执行,失败就整条回滚。
|
|
532
|
+
*
|
|
533
|
+
* 「哪些跑过了」由 {@link reconcile} 判定 —— **认的是 `id`,不是版本号**,
|
|
534
|
+
* 理由和那次事故都写在它的注释里。
|
|
535
|
+
*/
|
|
536
|
+
declare function migrate(db: SqliteDatabase, namespace: string, migrations: Migration[]): void;
|
|
537
|
+
/** 某个 namespace 当前的 schema 版本(没有迁移过返回 0) */
|
|
538
|
+
declare function schemaVersion(db: SqliteDatabase, namespace: string): number;
|
|
539
|
+
|
|
540
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
541
|
+
interface LogEntry {
|
|
542
|
+
level: LogLevel;
|
|
543
|
+
module: string;
|
|
544
|
+
message: string;
|
|
545
|
+
timestamp: string;
|
|
546
|
+
data?: Record<string, unknown>;
|
|
547
|
+
}
|
|
548
|
+
declare function setLogLevel(level: LogLevel): void;
|
|
549
|
+
declare function createLogger(module: string): {
|
|
550
|
+
debug(msg: string, data?: Record<string, unknown>): void;
|
|
551
|
+
info(msg: string, data?: Record<string, unknown>): void;
|
|
552
|
+
warn(msg: string, data?: Record<string, unknown>): void;
|
|
553
|
+
error(msg: string, data?: Record<string, unknown>): void;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 敏感值脱敏。
|
|
558
|
+
*
|
|
559
|
+
* 放 infra 而不是 config:logger 要用它做日志脱敏,config 要用它做展示,
|
|
560
|
+
* 两边都依赖它,而它自己是个纯字符串函数。原来长在 config/loader.ts 里,
|
|
561
|
+
* 导致 logger 为了脱敏反过来 import 配置模块。
|
|
562
|
+
*/
|
|
563
|
+
declare function maskApiKey(key: string): string;
|
|
564
|
+
/**
|
|
565
|
+
* 按字段名启发式判断是否敏感。
|
|
566
|
+
*
|
|
567
|
+
* **刻意选择「宁可多脱敏」**:`authorType` 这种字段名会被 `auth` 命中而误脱敏,
|
|
568
|
+
* 代价是调试时少看见一个无关字段;反过来漏掉一个真凭据的代价是它进日志、
|
|
569
|
+
* 进用户贴出来的 issue、进录屏。两边不对等,所以取子串匹配这一侧。
|
|
570
|
+
*/
|
|
571
|
+
declare function isSensitiveKey(name: string): boolean;
|
|
572
|
+
/** 脱敏一个对象里的敏感字段 */
|
|
573
|
+
declare function maskSensitive(data: Record<string, unknown>): Record<string, unknown>;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* 跨平台兼容层。
|
|
577
|
+
*
|
|
578
|
+
* 参考 Hermes _subprocess_compat.py — IS_WINDOWS + subprocess 创建标志。
|
|
579
|
+
*/
|
|
580
|
+
/** 是否为 Windows 平台 */
|
|
581
|
+
declare const IS_WINDOWS: boolean;
|
|
582
|
+
/** Windows 子进程隐藏窗口标志 [Hermes] */
|
|
583
|
+
declare const WINDOWS_HIDE_FLAGS: {
|
|
584
|
+
windowsHide: boolean;
|
|
585
|
+
} | {
|
|
586
|
+
windowsHide?: undefined;
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* 获取平台对应的 Python 命令名。
|
|
590
|
+
* Windows: python / py
|
|
591
|
+
* Unix: python3
|
|
592
|
+
*/
|
|
593
|
+
declare function getPythonCommand(): string;
|
|
594
|
+
/**
|
|
595
|
+
* Windows 上可选的 shell。**只收这三个具名值,不收任意可执行文件路径**——
|
|
596
|
+
* 每加一种就要重新验一遍「引号怎么escape、退出码怎么传」这两件事
|
|
597
|
+
* (见 {@link shellSpawnArgs} 的注释:两者在 cmd 和 PowerShell 上完全不同),
|
|
598
|
+
* 放开成任意路径等于承诺一件我们没验过的事。
|
|
599
|
+
*
|
|
600
|
+
* POSIX 上这个设置无效,永远 `/bin/sh` —— macOS 走的就是这一支。
|
|
601
|
+
*/
|
|
602
|
+
declare const SHELL_KINDS: readonly ["cmd", "powershell", "pwsh"];
|
|
603
|
+
/** Windows shell 选择 */
|
|
604
|
+
type ShellKind = (typeof SHELL_KINDS)[number];
|
|
605
|
+
/**
|
|
606
|
+
* 设置 Windows 上使用的 shell。传 `null`/`undefined` 恢复默认(`cmd`)。
|
|
607
|
+
*
|
|
608
|
+
* **在 POSIX 上是空操作**,不报错也不记状态 —— 调用方(runtime)不必自己判平台。
|
|
609
|
+
*/
|
|
610
|
+
declare function setShell(kind: ShellKind | null | undefined): void;
|
|
611
|
+
/**
|
|
612
|
+
* 当前生效的 shell 选择。优先级:`EPOCH_SHELL` 环境变量 > 配置 > `cmd`。
|
|
613
|
+
*
|
|
614
|
+
* 环境变量压过配置,跟 `EPOCH_PERMISSION` / `EPOCH_LOG_LEVEL` 一致;
|
|
615
|
+
* 值不认识就当没设(回到配置那一层),不抛异常 —— 这条路径在每次执行命令时
|
|
616
|
+
* 都会走到,为一个拼错的环境变量把 terminal 工具整个打死不划算。
|
|
617
|
+
* 校验和报错归配置层管(`EPOCH_SHELL` 在 loader 里会产出 issue)。
|
|
618
|
+
*/
|
|
619
|
+
declare function resolveShellKind(): ShellKind;
|
|
620
|
+
/**
|
|
621
|
+
* 获取平台对应的 Shell 可执行文件名。
|
|
622
|
+
* Windows: cmd.exe / powershell.exe / pwsh.exe(见 {@link resolveShellKind})
|
|
623
|
+
* Unix: /bin/sh
|
|
624
|
+
*/
|
|
625
|
+
declare function getDefaultShell(): string;
|
|
626
|
+
/**
|
|
627
|
+
* 探测某个 shell 在不在 PATH 上。可用返回 `null`,否则返回给用户看的说明。
|
|
628
|
+
*
|
|
629
|
+
* 存在的理由只有 `pwsh` 一个:cmd 和 powershell 是 Windows 自带的,
|
|
630
|
+
* pwsh(PowerShell 7+)要另装。配了没装的话,不探测的表现是**每一条命令**
|
|
631
|
+
* 都以 ENOENT 失败,而用户看到的只是「命令执行失败」—— 启动时说一次要便宜得多。
|
|
632
|
+
*
|
|
633
|
+
* 形状对齐同目录的 `ripgrep.ts`:同样是「外部可执行文件缺席」这类问题,
|
|
634
|
+
* 走同一条启动诊断通道。两者的差别在于 ripgrep 我们**随包带了一份**,
|
|
635
|
+
* 所以它有三档而这里只有两档 —— pwsh 没法随包分发。
|
|
636
|
+
*/
|
|
637
|
+
declare function probeShell(kind: ShellKind): string | null;
|
|
638
|
+
/**
|
|
639
|
+
* Windows 上把命令交给 cmd.exe 的**唯一正确形态**:`/d /s /c "<命令>"` + 逐字传参。
|
|
640
|
+
*
|
|
641
|
+
* 为什么不能写成 `spawn('cmd.exe', ['/c', command])`(本仓原来的写法):
|
|
642
|
+
* Node 在 Windows 上按 **CRT 规则**拼命令行 —— 参数里的 `"` 会被转义成 `\"`。
|
|
643
|
+
* 而 cmd.exe 不认 CRT 规则,`\"` 原样传给子程序,子程序的 CRT 再把 `\"` 解回
|
|
644
|
+
* 一个字面量引号。于是 `node -e "console.log(123)"` 到了 node 手里变成了
|
|
645
|
+
* **字符串字面量** `"console.log(123)"` —— 求值出来是个字符串,什么都不打印。
|
|
646
|
+
*
|
|
647
|
+
* 实测(本机 node v24.19.0):
|
|
648
|
+
* ```
|
|
649
|
+
* spawn('cmd.exe', ['/c', 'node -e "console.log(123)"']) → exit=0,stdout 空,stderr 空
|
|
650
|
+
* spawn('cmd.exe', ['/c', '"C:\\...\\node.exe" -v']) → exit=1,报 '\"...\"' 不是内部命令
|
|
651
|
+
* ```
|
|
652
|
+
* **退出码 0 + 无输出**,比报错还难查:模型只会以为「这条命令本来就没输出」。
|
|
653
|
+
*
|
|
654
|
+
* 修法照抄 Node 自己 `shell: true` 的做法(实测两者行为完全一致):
|
|
655
|
+
* - `/d` 跳过 AutoRun 注册表项(否则用户装的某些工具会往每个 cmd 里注入命令)
|
|
656
|
+
* - `/s` + 整条命令外面包一层引号:`/s` 的语义就是「剥掉首尾各一个引号,
|
|
657
|
+
* 中间的东西**原样**当命令」,内部引号因此不需要任何转义
|
|
658
|
+
* - `windowsVerbatimArguments` 关掉 Node 的 CRT 转义,让上面这层引号活下来
|
|
659
|
+
*/
|
|
660
|
+
interface ShellInvocation {
|
|
661
|
+
file: string;
|
|
662
|
+
args: string[];
|
|
663
|
+
/** 直接展开进 `spawn` 的 options */
|
|
664
|
+
options: {
|
|
665
|
+
windowsVerbatimArguments?: boolean;
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* 把命令拼成 PowerShell 的 `-Command` 参数串(含收尾语句)。
|
|
670
|
+
*
|
|
671
|
+
* 两个都是实测踩出来的,别按 cmd 的直觉改:
|
|
672
|
+
*
|
|
673
|
+
* 1. **内嵌 `"` 转义成 `\"`。** PowerShell 的 CLI 用自己的一套解析,
|
|
674
|
+
* 不是 cmd 的 `/s` 剥引号规则,也不是 CRT 规则。
|
|
675
|
+
*
|
|
676
|
+
* 2. **命令和收尾之间必须是真正的换行,不能用 `;`。** 用 `;` 的话,
|
|
677
|
+
* 命令末尾只要带一句 `# 注释`(模型很爱写),整段收尾语句就被注释掉了 ——
|
|
678
|
+
* 实测 `cmd /c exit 5 # note` 用 `;` 拼出来退出码是 **1**,
|
|
679
|
+
* 换成 CRLF 才是 5。这类失败不报错,只是**默默给出错误的退出码**。
|
|
680
|
+
*
|
|
681
|
+
* 导出是为了**用例**,理由和 `checkDangerousCommand` 的 `platform` 入参一样:
|
|
682
|
+
* `shellSpawnArgs` 走不走 PowerShell 那支由运行平台决定,不导出的话上面这两条
|
|
683
|
+
* 规则在 macOS 的 CI 上永远没人跑 —— 又是一个「写了、没验、坏了也不知道」。
|
|
684
|
+
* 它是纯函数,任何平台都能断言。
|
|
685
|
+
*/
|
|
686
|
+
declare function powershellCommandArg(command: string): string;
|
|
687
|
+
/**
|
|
688
|
+
* 给 `child_process.spawn` 用的 shell 调用形态。
|
|
689
|
+
*
|
|
690
|
+
* 三条路都靠 `windowsVerbatimArguments` 把命令行原样交出去,但**原因不同**:
|
|
691
|
+
* cmd 是为了让 `/s` 的那层引号活下来,PowerShell 是为了让 `\"` 和那个真换行
|
|
692
|
+
* 不被 Node 的 CRT 规则再动一次。
|
|
693
|
+
*/
|
|
694
|
+
declare function shellSpawnArgs(command: string): ShellInvocation;
|
|
695
|
+
/**
|
|
696
|
+
* 给 `node-pty` 用的 shell 调用形态。
|
|
697
|
+
*
|
|
698
|
+
* 不能复用 {@link shellSpawnArgs}:node-pty 在 Windows 上**自己有一套转义**,
|
|
699
|
+
* 数组形式会被它二次转义 —— 实测传 `['/d','/s','/c','"node -e ..."']` 直接
|
|
700
|
+
* 「系统找不到指定的路径」。它同时接受**字符串**形式的 args,那条路是逐字传的,
|
|
701
|
+
* 所以 Windows 上给字符串、POSIX 上给数组。
|
|
702
|
+
*/
|
|
703
|
+
declare function shellPtyArgs(command: string): {
|
|
704
|
+
file: string;
|
|
705
|
+
args: string | string[];
|
|
706
|
+
};
|
|
707
|
+
/**
|
|
708
|
+
* 把路径归一成「可以拿去跟固定字符串比对」的形状:分隔符统一成 `/`,全小写。
|
|
709
|
+
*
|
|
710
|
+
* 为什么需要它:敏感路径表里写的是 `.epoch/.env`,而 Windows 上传进来的
|
|
711
|
+
* target 是 `C:\Users\x\.epoch\.env` —— `includes('.epoch/.env')` 恒为 false,
|
|
712
|
+
* 于是**明文 API key 文件在 Windows 上根本不算敏感文件**。这类失效不报错,
|
|
713
|
+
* 只是安静地少拦一层。
|
|
714
|
+
*
|
|
715
|
+
* **只用于匹配,不要用返回值去访问文件系统。** 小写是无条件的(不是只在
|
|
716
|
+
* Windows 上做):这个函数只服务于黑名单比对,小写只会让黑名单命中得更多,
|
|
717
|
+
* 方向上是安全的那一侧;反过来在 Linux 上放过一个 `~/.SSH/` 才是真出事。
|
|
718
|
+
*/
|
|
719
|
+
declare function normalizeForMatch(path: string): string;
|
|
720
|
+
/**
|
|
721
|
+
* Windows 控制台代码页 → WHATWG 编码标签(`TextDecoder` 认的那套名字)。
|
|
722
|
+
*
|
|
723
|
+
* 背景:`cmd.exe` 的子进程按**控制台代码页**写字节,中文 Windows 默认 936(GBK),
|
|
724
|
+
* 而我们一路 `chunk.toString()` 按 UTF-8 解 —— `echo 中文测试` 出来就是
|
|
725
|
+
* `���IJ���`。这不是猜的,是实测:`echo 中文测试` 的原始字节是
|
|
726
|
+
* `d6d0 cec4 b2e2 cad4`,正好是 GBK 的「中文测试」。
|
|
727
|
+
*
|
|
728
|
+
* 表里没有的代码页(437 / 850 这些 DOS OEM 页,`TextDecoder` 不支持)回落到
|
|
729
|
+
* `utf-8`,也就是维持改动前的行为:ASCII 照样对,非 ASCII 照样乱。
|
|
730
|
+
* 宁可少修一部分,也不要在这里塞一张手写的 256 字符映射表。
|
|
731
|
+
*/
|
|
732
|
+
declare function encodingForCodePage(codePage: number): string;
|
|
733
|
+
/**
|
|
734
|
+
* 探测当前控制台的输出代码页,返回对应的编码标签。
|
|
735
|
+
*
|
|
736
|
+
* 非 Windows 直接 `utf-8`,一次子进程都不起。Windows 上跑一次 `chcp.com`
|
|
737
|
+
* (实测 18ms)并**缓存**——代码页在进程生命周期内不会变。Node 没有
|
|
738
|
+
* `GetConsoleOutputCP` 的绑定,这是不引第三方依赖的唯一路子。
|
|
739
|
+
*
|
|
740
|
+
* `chcp` 的提示语是本地化的(中文「活动代码页: 936」/ 英文 `Active code page: 437`),
|
|
741
|
+
* 所以只认输出里的**最后一串数字**,不认前缀。
|
|
742
|
+
*/
|
|
743
|
+
declare function detectConsoleEncoding(): string;
|
|
744
|
+
/** 流式解码器:跨 chunk 被切断的多字节字符不会被解坏 */
|
|
745
|
+
interface StreamDecoder {
|
|
746
|
+
/** 解一段 chunk;末尾不完整的字节序列留到下一次 */
|
|
747
|
+
write(chunk: Buffer): string;
|
|
748
|
+
/** 收尾,吐出残留字节(不完整序列变成替换字符) */
|
|
749
|
+
end(): string;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* 建一个解子进程输出的流式解码器:**UTF-8 优先,解不通才按控制台代码页解**。
|
|
753
|
+
*
|
|
754
|
+
* ## 为什么不能只挑一个编码
|
|
755
|
+
*
|
|
756
|
+
* Windows 上同一条管道里流的东西编码并不统一。实测(cmd.exe /c,stdout 是管道,
|
|
757
|
+
* 控制台 CP 936):
|
|
758
|
+
*
|
|
759
|
+
* | 谁在写 | 字节 | 实际编码 |
|
|
760
|
+
* | -------------------------- | ----------------------------------- | -------- |
|
|
761
|
+
* | cmd 内建 `echo 中文测试` | `d6d0 cec4 b2e2 cad4` | GBK |
|
|
762
|
+
* | 系统程序 `ver` 的「版本」 | `b0e6 b1be` | GBK |
|
|
763
|
+
* | node 脚本 `写('中文测试')` | `e4b8ad e69687 e6b58b e8af95` | UTF-8 |
|
|
764
|
+
*
|
|
765
|
+
* 所以写死 UTF-8(gemini-cli 非 PTY 路径的做法,`shellExecutionService.js:413`)
|
|
766
|
+
* 会让 `echo 中文` 变乱码;写死 GBK 会让 node / git / rg 这些跨平台工具的输出
|
|
767
|
+
* 变乱码。**两个都错,只是错在不同的命令上。**
|
|
768
|
+
*
|
|
769
|
+
* ## 为什么不用 `chcp 65001` 前置
|
|
770
|
+
*
|
|
771
|
+
* 方案里列的另一个候选,实测**无效**:
|
|
772
|
+
* `cmd /c "chcp 65001>nul&echo 中文测试"` 出来的还是 `d6d0cec4b2e2cad4`。
|
|
773
|
+
* gemini-cli 只在 **PTY** 路径上前置 chcp(`injectUtf8CodepageForPty`,明说
|
|
774
|
+
* 因为 `CreatePseudoConsole` 不收代码页参数),非 PTY 路径它压根没用这招。
|
|
775
|
+
* 而且前置 chcp 会污染命令串本身——用户看到的 / 审批过的命令和真正执行的不是一条。
|
|
776
|
+
*
|
|
777
|
+
* ## 所以按字节判
|
|
778
|
+
*
|
|
779
|
+
* ASCII 在两种编码下完全一致,直接放行、不参与判定;从**第一个非 ASCII 字节**起
|
|
780
|
+
* 用 `fatal: true` 的 UTF-8 试解一次:解得通就是 UTF-8,抛异常就按控制台代码页。
|
|
781
|
+
* 判定一次就定死,之后整条流不再改主意(同一个进程不会中途换编码)。
|
|
782
|
+
*
|
|
783
|
+
* 这不是玄学:GBK 的双字节是「首字节 0x81–0xFE + 尾字节 0x40–0xFE」,尾字节落在
|
|
784
|
+
* 0x40–0x7F 时直接违反 UTF-8 的续字节规则(必须 0x80–0xBF),一撞就抛。
|
|
785
|
+
*
|
|
786
|
+
* ## 两条已知残余(都不比改动前差,如实写在这里)
|
|
787
|
+
*
|
|
788
|
+
* 1. **一小段 GBK 恰好也是合法 UTF-8**:比如 `c4a3` 既是 GBK 的「模」也是 UTF-8 的
|
|
789
|
+
* U+0123。字数一多概率就塌到可以忽略,但单字符时会解错。
|
|
790
|
+
* 2. **一条命令里混两种编码**:`echo 中文 & node -e "写('中文')"` —— 前半是 GBK、
|
|
791
|
+
* 后半是 UTF-8,走的是同一条 stdout。判定在第一个非 ASCII 字节做完就定死,
|
|
792
|
+
* 所以后半会解成 `涓枃`(实测)。做成「逐 chunk 重判」能修掉它,代价是要为每种
|
|
793
|
+
* 回落编码各写一个「尾部是否截断」的扫描器(GBK 不自同步、Shift-JIS 还有单字节
|
|
794
|
+
* 半角假名),而且两处状态一旦不同步就是更难查的错。**权衡后选择不做**:
|
|
795
|
+
* 改动前这条命令的**两半都是**乱码,现在只剩后半。
|
|
796
|
+
*
|
|
797
|
+
* @param fallbackEncoding UTF-8 解不通时用的编码,不传则探测控制台代码页
|
|
798
|
+
*/
|
|
799
|
+
declare function createStreamDecoder(fallbackEncoding?: string): StreamDecoder;
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* ripgrep 的三级解析(方案 24)—— 回答「`rg` 从哪来」这一个问题。
|
|
803
|
+
*
|
|
804
|
+
* 1. `system` —— PATH 里有 `rg` 就用它。用户自己装的通常比我们内置的新
|
|
805
|
+
* 2. `builtin` —— 用 `@epoch-agent/vendor-ripgrep-<platform>-<arch>` 里带的那个
|
|
806
|
+
* 3. `missing` —— 两个都没有。**保持内置之前的行为**:启动诊断一条 warn,
|
|
807
|
+
* `file_search` 仍然注册,调用时报错并给安装提示
|
|
808
|
+
*
|
|
809
|
+
* 第 3 级不是兜底摆设,是真的会走到:`npm install --ignore-scripts`、
|
|
810
|
+
* 内网私服重打包、以及我们没出包的平台(Linux 现阶段就是)。那种情况下退回旧行为
|
|
811
|
+
* 比崩掉好得多 —— 少一个工具,不是起不来。
|
|
812
|
+
*
|
|
813
|
+
* 放 infra 而不是 plugin-file:将来 `file_list`(`rg --files`)和 `@` 文件补全
|
|
814
|
+
* 都要用它,而 plugin 之间不许互相依赖。
|
|
815
|
+
*
|
|
816
|
+
* ## 为什么两种模式的 `command` 形态不一样
|
|
817
|
+
*
|
|
818
|
+
* `system` 给的是**命令名 `'rg'`**,`builtin` 给的是**绝对路径**。这不是疏漏,
|
|
819
|
+
* 两边是刻意不对称的,改之前先读下面两段。
|
|
820
|
+
*/
|
|
821
|
+
/** 解析结果的三档。`missing` 也是一种正常结果,不是错误 */
|
|
822
|
+
type RipgrepMode = 'system' | 'builtin' | 'missing';
|
|
823
|
+
interface RipgrepResolution {
|
|
824
|
+
mode: RipgrepMode;
|
|
825
|
+
/**
|
|
826
|
+
* `spawn` 的第一个参数。
|
|
827
|
+
*
|
|
828
|
+
* - `system`:**故意是 `'rg'` 而不是探测到的绝对路径**。见下面 `probeSystem` 的注释
|
|
829
|
+
* - `builtin`:vendor 包里那个文件的绝对路径
|
|
830
|
+
* - `missing`:仍然是 `'rg'`,让调用方照旧 spawn 并拿到 ENOENT ——
|
|
831
|
+
* 这样「没装」这条路径与内置之前**逐字同构**,错误信息和 suggestion 都不用改
|
|
832
|
+
*/
|
|
833
|
+
command: string;
|
|
834
|
+
/** 内置那份的版本号(vendor 包的 `version`,与上游 ripgrep 版本对齐)。只有 `builtin` 有 */
|
|
835
|
+
version?: string;
|
|
836
|
+
/** 安装提示。只有 `missing` 有 —— 另外两档不需要用户做任何事 */
|
|
837
|
+
hint?: string;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* ripgrep 没装时给出的安装命令。
|
|
841
|
+
*
|
|
842
|
+
* 单独抽出来是因为**启动诊断和工具报错都要用同一份**:诊断里说「装法见 X」、
|
|
843
|
+
* 工具报错里说「装法见 Y」,两份说法早晚会不一致。
|
|
844
|
+
*
|
|
845
|
+
* Linux 那一支照旧保留(CLAUDE.md:Linux 是排在后面,不是不做)——
|
|
846
|
+
* 我们没为 Linux 出 vendor 包,所以 Linux 用户命中的正是 `missing` 这一档,
|
|
847
|
+
* 那条文案对他们是**唯一**的指引,删掉等于让他们没法自救。
|
|
848
|
+
*/
|
|
849
|
+
declare const RIPGREP_INSTALL_HINT: string;
|
|
850
|
+
/** 测试用的注入点。生产调用一律不传参,走 memoize 那条路 */
|
|
851
|
+
interface ResolveRipgrepOptions {
|
|
852
|
+
env?: NodeJS.ProcessEnv;
|
|
853
|
+
platform?: string;
|
|
854
|
+
arch?: string;
|
|
855
|
+
/** 文件存在性判定(探测 PATH、判 vendor 二进制在不在) */
|
|
856
|
+
fileExists?: (path: string) => boolean;
|
|
857
|
+
/** 解析 vendor 包的 package.json 绝对路径;解析不到返回 null */
|
|
858
|
+
resolveVendor?: (specifier: string) => string | null;
|
|
859
|
+
/** 确保 vendor 二进制可执行,返回「现在可执行了吗」。见 `ensureExecutable` */
|
|
860
|
+
makeExecutable?: (path: string) => boolean;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* 解析出这台机器上该用哪个 `rg`。
|
|
864
|
+
*
|
|
865
|
+
* 进程内只算一次 —— PATH 和 vendor 目录在一个进程的生命周期里不会变。
|
|
866
|
+
*
|
|
867
|
+
* @param options 仅测试注入。**传了就不走缓存也不写缓存**,
|
|
868
|
+
* 否则一个用例的假环境会漏给后面所有用例
|
|
869
|
+
*/
|
|
870
|
+
declare function resolveRipgrep(options?: ResolveRipgrepOptions): RipgrepResolution;
|
|
871
|
+
/** 清掉 memoize 的结果。**只给测试用** */
|
|
872
|
+
declare function resetRipgrepCache(): void;
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* @license
|
|
876
|
+
* Copyright 2025 Google LLC
|
|
877
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
878
|
+
*
|
|
879
|
+
* 衍生自 gemini-cli `packages/core/src/utils/process-utils.ts` 的
|
|
880
|
+
* `killProcessGroup()`。已做的修改:
|
|
881
|
+
* - 增加 `detached` 开关且**默认 false**:只有调用方明确声明子进程是
|
|
882
|
+
* `detached: true` 起的,才允许 `process.kill(-pid)`。原版无条件杀进程组,
|
|
883
|
+
* 对非 detached 的子进程等于杀自己(见下方「为什么默认不杀进程组」)。
|
|
884
|
+
* - `platform` 改成可注入入参,让 Windows 分支在 Mac 上也能被用例覆盖到。
|
|
885
|
+
* - 入口挡死 `pid <= 1`(原版没挡,`kill(-0)` / `kill(-1)` 的后果见下)。
|
|
886
|
+
* - 后代收集从递归 DFS 改成带 `seen` 去重的 BFS,并加深度 / 数量上限,
|
|
887
|
+
* 且拆成独立导出的 `collectProcessTree()` —— 「先优雅关闭、关完再清扫」
|
|
888
|
+
* 的场景必须能把收集和下手分成两步(见 `killPids`)。
|
|
889
|
+
* - 不引 gemini-cli 的 `spawnAsync`(它挂着沙箱管理器依赖,跟 infra 的
|
|
890
|
+
* 零内部依赖定位冲突),改用 `execFile` 的手写 promise 包装。
|
|
891
|
+
* - Windows 的进程树采集(`readWindowsSnapshot` / `buildChildrenMap` 及其两个
|
|
892
|
+
* 解析器)是**原创**,gemini-cli 没有对应实现 —— 它在 Windows 上只有
|
|
893
|
+
* `taskkill /t` 一条路。
|
|
894
|
+
* Modifications Copyright 2024-2026 bowen.
|
|
895
|
+
*/
|
|
896
|
+
/** SIGTERM 之后等多久补 SIGKILL(gemini-cli 的取值,实测够一个 shell 退干净) */
|
|
897
|
+
declare const SIGKILL_TIMEOUT_MS = 200;
|
|
898
|
+
/** 能被杀的 PTY 句柄(只声明用得到的部分,避免 infra 依赖 node-pty 的类型) */
|
|
899
|
+
interface KillablePty {
|
|
900
|
+
kill(signal?: string): void;
|
|
901
|
+
}
|
|
902
|
+
/** 信号发送策略,{@link killProcessTree} 和 {@link killPids} 共用 */
|
|
903
|
+
interface KillSignalOptions {
|
|
904
|
+
/** 先 SIGTERM、等 {@link killTimeoutMs} 之后没退再 SIGKILL。默认 false(直接 SIGKILL) */
|
|
905
|
+
escalate?: boolean;
|
|
906
|
+
/** 首发信号。不传时按 `escalate` 决定是 SIGTERM 还是 SIGKILL */
|
|
907
|
+
signal?: NodeJS.Signals;
|
|
908
|
+
/** 进程是否已退出。`escalate` 时用它决定要不要补刀 */
|
|
909
|
+
isExited?: () => boolean;
|
|
910
|
+
/** SIGTERM → SIGKILL 的等待时长,默认 {@link SIGKILL_TIMEOUT_MS} */
|
|
911
|
+
killTimeoutMs?: number;
|
|
912
|
+
}
|
|
913
|
+
/** {@link killProcessTree} 的入参 */
|
|
914
|
+
interface KillProcessTreeOptions extends KillSignalOptions {
|
|
915
|
+
/** 目标进程号(起它的那个 spawn 返回的 `child.pid`) */
|
|
916
|
+
pid: number;
|
|
917
|
+
/**
|
|
918
|
+
* 子进程是否用 `detached: true` 起的。
|
|
919
|
+
* **只有 true 才会走 `kill(-pid)` 杀进程组**,理由见文件头。默认 false。
|
|
920
|
+
*/
|
|
921
|
+
detached?: boolean;
|
|
922
|
+
/** 同时要收掉的 PTY 句柄 */
|
|
923
|
+
pty?: KillablePty;
|
|
924
|
+
/** 平台,默认取当前进程。显式传值是为了让 Windows 分支在 Mac 上也跑得到 */
|
|
925
|
+
platform?: NodeJS.Platform;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* 杀掉一个进程及其全部后代。**要求调用时这个进程还活着**——按 ppid 收树的
|
|
929
|
+
* 前提就是链条还在,父进程一死后代立刻被 init 收养,再查就查不到了。
|
|
930
|
+
* 「必须先优雅关闭」的场景请改用 {@link collectProcessTree} + {@link killPids}。
|
|
931
|
+
*
|
|
932
|
+
* 幂等:进程已经退出、pid 已被回收、命令不存在,一律静默返回,不抛异常。
|
|
933
|
+
* 调用方不需要(也不应该)为「可能已经死了」写分支。
|
|
934
|
+
*
|
|
935
|
+
* Windows 走三层:快照出来的后代逐个 → PTY 句柄 → `taskkill /f /t` 收尾;
|
|
936
|
+
* POSIX 走三层:进程组(仅 detached)→ 后代树逐个 → PTY 句柄。三层缺一不可,
|
|
937
|
+
* 因为「后代自己又 detach 出去」的情况下它已经不在原来那个组里了。
|
|
938
|
+
*/
|
|
939
|
+
declare function killProcessTree(options: KillProcessTreeOptions): Promise<void>;
|
|
940
|
+
/** {@link killPids} 的额外目标:进程组、PTY 句柄 */
|
|
941
|
+
interface ExtraTargets {
|
|
942
|
+
/** 传了就额外发一发 `kill(-pid)`。**只有确实 detach 过的 pid 才能传** */
|
|
943
|
+
groupLeaderPid?: number;
|
|
944
|
+
pty?: KillablePty;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* 杀掉一组**已知的** pid,不做任何探测。
|
|
948
|
+
*
|
|
949
|
+
* 存在的理由是「必须先优雅关闭」的场景 —— 典型是 MCP 的 stdio transport:
|
|
950
|
+
* SDK 的 `close()` 是 stdin EOF → 等 2s → SIGTERM → 等 2s → SIGKILL,这套
|
|
951
|
+
* 优雅流程不能绕过;但它一旦把直接子进程收掉,孙进程立刻被 init 收养,
|
|
952
|
+
* ppid 链断了,那时候再 `pgrep -P` 什么都查不到。所以必须拆成两步:
|
|
953
|
+
*
|
|
954
|
+
* ```ts
|
|
955
|
+
* const orphans = await collectProcessTree(pid); // ppid 链还在,先拍快照
|
|
956
|
+
* await transport.close(); // 优雅关闭,直接子进程它自己收
|
|
957
|
+
* await killPids(orphans, { escalate: true }); // 再收拾被 init 收养的那些
|
|
958
|
+
* ```
|
|
959
|
+
*
|
|
960
|
+
* 传进来的顺序就是发信号的顺序,调用方自己负责「叶子在前」
|
|
961
|
+
* ({@link collectProcessTree} 的返回值已经是这个顺序)。
|
|
962
|
+
*/
|
|
963
|
+
declare function killPids(pids: readonly number[], options?: KillSignalOptions, extra?: ExtraTargets): Promise<void>;
|
|
964
|
+
/**
|
|
965
|
+
* 广度优先收集全部后代,**叶子在前、根不含在内**。
|
|
966
|
+
*
|
|
967
|
+
* 叶子在前是刻意的:父进程先死的话它的 pid 可能立刻被系统回收给别人,
|
|
968
|
+
* 后续那一发信号就打在无关进程上了。
|
|
969
|
+
*
|
|
970
|
+
* 按 ppid 查而不是按进程组查:孙进程如果自己 detach 出去了,
|
|
971
|
+
* 它已经不在原来的组里,但 ppid 关系还在。
|
|
972
|
+
*
|
|
973
|
+
* `seen` 去重不是洁癖:pid 是会回收复用的,遍历途中理论上能凑出环,
|
|
974
|
+
* 原版的递归实现遇到就是栈溢出。
|
|
975
|
+
*
|
|
976
|
+
* **两个平台的采集方式不同,但遍历是同一套**:POSIX 每层跑一次 `pgrep -P`;
|
|
977
|
+
* Windows 没有 `pgrep`,改成**先拍一张全系统快照**、后面整棵树都在内存里走
|
|
978
|
+
* (见 {@link readWindowsSnapshot})—— 每层各起一次 `wmic` 是 158ms × 层数 × 宽度,
|
|
979
|
+
* 不能那么干。快照还顺带把「同一时刻」这件事做实了:逐层查会拍到不一致的中间态。
|
|
980
|
+
*/
|
|
981
|
+
declare function collectProcessTree(rootPid: number, platform?: NodeJS.Platform): Promise<number[]>;
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 危险命令检测 —— 全局唯一一份表。
|
|
985
|
+
*
|
|
986
|
+
* 之前 core/permission/manager.ts 和 plugin-terminal/index.ts 各维护一份
|
|
987
|
+
* **内容不同**的正则表:core 认得 `rm -rf /` 但不认 `mkfs.`/`dd if=`,
|
|
988
|
+
* terminal 反过来。两处都能被对方漏掉的写法绕过,而且改一处忘另一处。
|
|
989
|
+
*
|
|
990
|
+
* 定位要说清楚:这是**启发式护栏**,不是沙箱。它拦的是「模型想干傻事」,
|
|
991
|
+
* 拦不住刻意混淆(`$(echo rm) -rf /`、base64 解码后执行之类)。
|
|
992
|
+
* 真要防恶意代码得靠 OS 级隔离,见 sandbox/sandbox.ts 的说明。
|
|
993
|
+
*
|
|
994
|
+
* ## 判定建在分词结果上,不建在原串上
|
|
995
|
+
*
|
|
996
|
+
* 原来的实现是 `replace(/["']/g, '')` 剥引号 + 正则打整串。那条路同时误杀和漏网,
|
|
997
|
+
* 而且根因是同一个 —— **剥掉引号之后,「命令词」和「字符串字面量」就分不开了**:
|
|
998
|
+
*
|
|
999
|
+
* git commit -m "fix: remove sudo from docs" → 判成「提权执行」(误杀)
|
|
1000
|
+
* echo "just documenting rm -rf / here" → 判成「递归删除根目录」(误杀)
|
|
1001
|
+
* rm --recursive --force / → 漏网(`-[a-z]*r[a-z]*` 收不到长选项)
|
|
1002
|
+
*
|
|
1003
|
+
* 现在的形状是 [shell-parse.ts](./shell-parse.ts) 先切成「若干段,每段一个
|
|
1004
|
+
* root + 一串词」,规则声明自己命中哪个 **root**、再对 **argv** 提附加条件。
|
|
1005
|
+
*
|
|
1006
|
+
* **规则一律不拿自由文本去匹配。** `sudo` 危险是因为 root 是 `sudo`,
|
|
1007
|
+
* 不是因为命令里出现了 "sudo" 这五个字母 —— 这条分界是整个文件的地基,
|
|
1008
|
+
* 谁想加一条「在整串上打正则」的规则,先读一遍上面那三个例子。
|
|
1009
|
+
* (唯一的例外是 fork bomb,它是语法构造不是命令,见 `WHOLE_STRING_RULES`。)
|
|
1010
|
+
*/
|
|
1011
|
+
interface DangerMatch {
|
|
1012
|
+
dangerous: boolean;
|
|
1013
|
+
desc?: string;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* 「放行了它就等于放行一切」的命令前缀清单。
|
|
1017
|
+
*
|
|
1018
|
+
* **本文件不消费它** —— 它是给[方案 22](../../../docs/verify/VERIFY_RECORD-22-permissions.md)
|
|
1019
|
+
* 的权限规则层用的:`terminal(python:*)` 这种 allow 规则要被拒绝加载,
|
|
1020
|
+
* 因为放行 `python` 开头的任何命令等于放行任意代码执行。
|
|
1021
|
+
* 而 `terminal(pnpm test:*)` 是可以的 —— `pnpm test` 之后的参数换不成别的脚本名。
|
|
1022
|
+
*
|
|
1023
|
+
* 注意它和 `INLINE_CODE_EXEC` **不是一回事,边界也不同**:
|
|
1024
|
+
*
|
|
1025
|
+
* | | 管什么 | 为什么边界不同 |
|
|
1026
|
+
* | --- | --- | --- |
|
|
1027
|
+
* | `INLINE_CODE_EXEC` | 每次调用要不要人确认 | 必须窄,否则天天弹框 |
|
|
1028
|
+
* | `CODE_EXEC_ROOTS` | 能不能写进 allow 规则 | 可以宽,写规则是一次性的 |
|
|
1029
|
+
*
|
|
1030
|
+
* 所以 `npx` / `npm run` 在这里而**不在**上面那张表里:
|
|
1031
|
+
* 本仓库自己就到处是 `npx tsc` / `npx vitest`,每次都弹框没人受得了;
|
|
1032
|
+
* 但「一条 allow 规则永久放行所有 `npx`」是另一回事,那个必须拦。
|
|
1033
|
+
*/
|
|
1034
|
+
declare const CODE_EXEC_ROOTS: readonly string[];
|
|
1035
|
+
/** 判定用哪张表。不传就按当前平台。 */
|
|
1036
|
+
type DangerPlatform = 'win32' | 'posix';
|
|
1037
|
+
interface DangerCheckOptions {
|
|
1038
|
+
/**
|
|
1039
|
+
* 显式指定按哪张表判定。
|
|
1040
|
+
*
|
|
1041
|
+
* 这个入参存在的唯一理由是**用例**:不显式传的话,Windows 表在 macOS 的 CI 上
|
|
1042
|
+
* 永远不会被执行,那就又是一个「静默失效」——表写了、没人跑、坏了也不知道。
|
|
1043
|
+
*/
|
|
1044
|
+
platform?: DangerPlatform;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* 检测命令是否危险。
|
|
1048
|
+
*
|
|
1049
|
+
* **解析失败时返回「不危险」是刻意的**:那种情况归 `checkObfuscation()` 管
|
|
1050
|
+
* (语义是「我判断不了,请人看一眼」),两个函数各管一件事。调用方两个都要问。
|
|
1051
|
+
*
|
|
1052
|
+
* @param command 完整命令串
|
|
1053
|
+
* @param opts 可显式指定平台;默认按当前运行平台选表
|
|
1054
|
+
*/
|
|
1055
|
+
declare function checkDangerousCommand(command: string, opts?: DangerCheckOptions): DangerMatch;
|
|
1056
|
+
/**
|
|
1057
|
+
* 是否为只读命令。
|
|
1058
|
+
*
|
|
1059
|
+
* 两道:**先看有没有副作用通道**(重定向 / 管道 / 串联 / 命令替换),
|
|
1060
|
+
* 再看每一段的 root 是不是都在白名单里。
|
|
1061
|
+
*
|
|
1062
|
+
* 顺序不能反:`ls > /etc/passwd` 和 `ls; rm -rf /` 的第一段 root 都是 `ls`,
|
|
1063
|
+
* 只看 root 会把它们放行。
|
|
1064
|
+
*/
|
|
1065
|
+
declare function isReadOnlyCommand(command: string): boolean;
|
|
1066
|
+
/**
|
|
1067
|
+
* 检测混淆构造。
|
|
1068
|
+
* @returns 命中时返回描述;否则 null
|
|
1069
|
+
*/
|
|
1070
|
+
declare function checkObfuscation(command: string): string | null;
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* 隔离层平台分发。
|
|
1074
|
+
*
|
|
1075
|
+
* 各平台的实现能力:
|
|
1076
|
+
* macOS → Seatbelt(sandbox-exec) 已在本机验证
|
|
1077
|
+
* Linux → bubblewrap(bwrap) 参数构造有测试,运行时未在本机验证
|
|
1078
|
+
* Windows → 无 见下方说明
|
|
1079
|
+
*
|
|
1080
|
+
* 为什么 Windows 没有:等价能力需要 AppContainer(CreateProcess 时传
|
|
1081
|
+
* SECURITY_CAPABILITIES)或 Job Object + AppContainer profile,这些只有
|
|
1082
|
+
* Win32 API,Node 里没有绑定,得写 native addon。Job Object 单独用只能限
|
|
1083
|
+
* CPU/内存,管不了文件系统和网络——那不构成隔离,所以不假装有。
|
|
1084
|
+
* Windows 上 `getIsolation()` 会如实返回 'process'。
|
|
1085
|
+
*
|
|
1086
|
+
* ⚠️ 上面那段判据**漏了第三条路**(受限令牌 + ACL),调研结论和为什么这一轮
|
|
1087
|
+
* 仍然没做,记在 [方案 46 §6.2](../../../../.agents/plans/46-terminal-sandbox-plan.md)。
|
|
1088
|
+
*
|
|
1089
|
+
* ## 为什么这个文件在 infra 而不在 core
|
|
1090
|
+
*
|
|
1091
|
+
* 2026-08-16 从 `core/src/sandbox/` 整体搬过来的。判据只有一条:
|
|
1092
|
+
* **消费者不止 core 一个了。** `plugin-terminal` 按分层只许依赖
|
|
1093
|
+
* protocol + infra([check-layers.mjs](../../../../scripts/check-layers.mjs) 的
|
|
1094
|
+
* `ALLOWED` 表),够不着 core —— 而方案 46 要做的正是把它接上隔离层。
|
|
1095
|
+
* 先例是同一个目录里的 [command-safety.ts](../command-safety.ts):危险命令表
|
|
1096
|
+
* 也是权限层和 `plugin-terminal` 共用的一份纯函数,它一直住在这儿。
|
|
1097
|
+
*
|
|
1098
|
+
* **两样刻意没跟着搬**,都留在 `core/src/sandbox/isolation.ts`:
|
|
1099
|
+
*
|
|
1100
|
+
* 1. `SANDBOX_COVERS` / `SANDBOX_EXCLUDES` —— 那是工具名,领域知识
|
|
1101
|
+
* 2. `describeIsolation()` 和它那句中文 `detail` —— infra 是「无状态叶子能力」,
|
|
1102
|
+
* 产用户可见散文不是它的活(同 `logger.ts` 之外的其余文件都不产文案)。
|
|
1103
|
+
* 它只要这里的 {@link detectBackend},一个函数调用的事
|
|
1104
|
+
*
|
|
1105
|
+
* 这个文件只回答「这台机器上有什么后端、怎么把 argv 包进去」。
|
|
1106
|
+
*/
|
|
1107
|
+
interface IsolationOptions {
|
|
1108
|
+
/** 可读写目录 */
|
|
1109
|
+
writableDirs: string[];
|
|
1110
|
+
/** 只读可见目录(工作区、解释器) */
|
|
1111
|
+
readableDirs?: string[];
|
|
1112
|
+
/** 是否允许网络 */
|
|
1113
|
+
allowNetwork: boolean;
|
|
1114
|
+
/** 子进程工作目录(部分后端需要显式指定) */
|
|
1115
|
+
cwd?: string;
|
|
1116
|
+
/**
|
|
1117
|
+
* 读取策略。默认 `whitelist` —— 与 2026-08-16 之前逐字节相同的行为。
|
|
1118
|
+
*
|
|
1119
|
+
* `unrestricted` 是方案 46 给 `terminal` 开的那一档,判据在
|
|
1120
|
+
* [policy.ts](./policy.js) 的 {@link SandboxMode} 上:沙箱模式**只管写入**。
|
|
1121
|
+
*/
|
|
1122
|
+
reads?: 'whitelist' | 'unrestricted';
|
|
1123
|
+
}
|
|
1124
|
+
interface IsolatedCommand {
|
|
1125
|
+
command: string;
|
|
1126
|
+
args: string[];
|
|
1127
|
+
}
|
|
1128
|
+
type IsolationBackend = 'seatbelt' | 'bubblewrap' | 'none';
|
|
1129
|
+
/** 当前平台可用的隔离后端 */
|
|
1130
|
+
declare function detectBackend(): IsolationBackend;
|
|
1131
|
+
/**
|
|
1132
|
+
* 把命令包进当前平台的隔离层。
|
|
1133
|
+
*
|
|
1134
|
+
* ⚠️ 这是 `code_exec` 那条路的接缝,形状是三个散参数。新的消费者走
|
|
1135
|
+
* [confine()](./policy.js) —— 它按调用带一个 policy 对象,而且**如实报出
|
|
1136
|
+
* 强制完整度**。两个并存不是重复:这一个的调用点(`CodeSandbox.run()`)
|
|
1137
|
+
* 传的是「沙箱临时目录 + 默认断网」,和终端那条路一个字都不一样。
|
|
1138
|
+
*
|
|
1139
|
+
* @returns 包装后的命令;无可用后端时返回 null(调用方需降级并告知用户)
|
|
1140
|
+
*/
|
|
1141
|
+
declare function isolate(command: string, args: string[], opts: IsolationOptions): IsolatedCommand | null;
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* 命令失败之后的两组分类器 —— 「被沙箱挡住」和「沙箱没起来」是两句不同的话。
|
|
1145
|
+
*
|
|
1146
|
+
* 这是方案 46 §五。它要防的是一类很具体的误导:命令挂了之后,用户和模型
|
|
1147
|
+
* 只看到一个非零退出码,于是把**沙箱自己崩了**读成**这条命令失败了**,
|
|
1148
|
+
* 然后去改命令 —— 而命令根本没跑过。
|
|
1149
|
+
*
|
|
1150
|
+
* | 失败种类 | 事实 | 该说的话 |
|
|
1151
|
+
* | --------------- | -------------------------------- | ------------------------------------ |
|
|
1152
|
+
* | `sandbox-denied` | 沙箱工作正常,它挡住了这条命令 | 「这条命令想写边界外的文件,被挡了」 |
|
|
1153
|
+
* | `runner-failed` | 沙箱自己没起来,命令**根本没跑** | 「沙箱启动失败,命令没有执行」 |
|
|
1154
|
+
* | `command-failed` | 沙箱没插话,就是命令自己失败了 | 原样把 stderr 交出去 |
|
|
1155
|
+
*
|
|
1156
|
+
* ## 两条规矩,抄 dsh
|
|
1157
|
+
*
|
|
1158
|
+
* **一、拒绝方言只匹配当前后端那一组。** 每个后端拒绝一次文件操作产生的
|
|
1159
|
+
* stderr 特征是不一样的(Seatbelt 是 EPERM,bwrap 只读绑定是 EROFS)。
|
|
1160
|
+
* 跨后端取并集的下场是「在 mac 上把一个 EROFS 报成沙箱拒绝」这种假归因 ——
|
|
1161
|
+
* 而 EROFS 在 mac 上的真实来源是只读挂载的磁盘映像,跟沙箱一点关系没有。
|
|
1162
|
+
* 所以这里是 {@link DENIAL_SIGNATURES} 这张**按后端分组**的表,
|
|
1163
|
+
* `confine()` 把调用方那一组一并交出去,消费者不许自己合并。
|
|
1164
|
+
*
|
|
1165
|
+
* **二、runner 失败要两个条件同时成立。** 光看退出码永远不算证据 ——
|
|
1166
|
+
* 被挡住的命令自己也非零退出。规则是:非零退出,**并且**排除掉良性信息行
|
|
1167
|
+
* 之后剩下的 stderr 里还有一条致命特征行。第 2 步的「排除良性行」不能省,
|
|
1168
|
+
* 否则后端打一句 deprecated 警告就满地假红(方案 46 验收 7 盯的就是这个)。
|
|
1169
|
+
*
|
|
1170
|
+
* **匹配到的那一行留作错误详情,分类不重写 stderr** —— 用户要看的还是原话。
|
|
1171
|
+
*/
|
|
1172
|
+
|
|
1173
|
+
/** 一次失败到底是哪一种。三档互斥,判定顺序见 {@link classifyFailure} */
|
|
1174
|
+
type FailureKind = 'sandbox-denied' | 'runner-failed' | 'command-failed';
|
|
1175
|
+
/**
|
|
1176
|
+
* 判 runner 失败要用的两组正则。
|
|
1177
|
+
*
|
|
1178
|
+
* 两组都**按行匹配**(消费者逐行过),不是整块 stderr 上跑 —— 整块跑的话
|
|
1179
|
+
* 一行良性 + 一行致命会被算成「整块良性」或者反过来,取决于谁先匹配上。
|
|
1180
|
+
*/
|
|
1181
|
+
interface RunnerFailureRules {
|
|
1182
|
+
/** 致命特征:出现这一行就说明 runner 自己没起来 */
|
|
1183
|
+
fatal: readonly RegExp[];
|
|
1184
|
+
/**
|
|
1185
|
+
* 良性信息行:先把它们剔掉,再看还剩不剩致命行。
|
|
1186
|
+
*
|
|
1187
|
+
* ⚠️ 良性表必须比致命表**更具体**,否则它会把致命行一起吃掉。
|
|
1188
|
+
* 这里的写法是「同一个前缀 + 一个明确的良性尾巴」,靠的是这一点。
|
|
1189
|
+
*/
|
|
1190
|
+
benign: readonly RegExp[];
|
|
1191
|
+
/**
|
|
1192
|
+
* 这些退出码不算失败(默认只有 0)。
|
|
1193
|
+
*
|
|
1194
|
+
* 留着这个口子是因为有些命令拿非零退出当正常结果(`grep` 没匹配到是 1,
|
|
1195
|
+
* `diff` 有差异是 1)。今天没有调用方用它 —— 但没有它的话,将来那个调用方
|
|
1196
|
+
* 只能去改分类器本身。
|
|
1197
|
+
*/
|
|
1198
|
+
allowedExitCodes?: readonly number[];
|
|
1199
|
+
}
|
|
1200
|
+
/** 分类结论。`evidence` 是**原话**,不是重写过的措辞 */
|
|
1201
|
+
interface FailureVerdict {
|
|
1202
|
+
kind: FailureKind;
|
|
1203
|
+
/** 命中的那一行 stderr 原文。没命中任何一组时不带 */
|
|
1204
|
+
evidence?: string;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 拒绝方言 —— **按后端分组,不许取并集**(判据见文件头)。
|
|
1208
|
+
*
|
|
1209
|
+
* | 后端 | 特征 |
|
|
1210
|
+
* | -------------- | ---------------------------------- |
|
|
1211
|
+
* | Seatbelt | `Operation not permitted`(EPERM) |
|
|
1212
|
+
* | bwrap 只读绑定 | `Read-only file system`(EROFS) |
|
|
1213
|
+
*
|
|
1214
|
+
* `none` 是**空数组,而且必须是空的**:没有后端就没有沙箱拒绝这回事,
|
|
1215
|
+
* 给它一组签名等于在没装沙箱的机器上把普通的权限错误报成「被沙箱挡了」。
|
|
1216
|
+
*
|
|
1217
|
+
* 写成 `Record<IsolationBackend, …>` 而不是查表函数加 default:PR-3 给
|
|
1218
|
+
* `IsolationBackend` 加上 `'windows-acl'` 那天,这个字面量**当场编译不过**,
|
|
1219
|
+
* 而 default 分支只会安静地返回空数组、让 Windows 上的每一次拒绝都被
|
|
1220
|
+
* 报成普通命令失败。
|
|
1221
|
+
*/
|
|
1222
|
+
declare const DENIAL_SIGNATURES: Record<IsolationBackend, readonly RegExp[]>;
|
|
1223
|
+
/**
|
|
1224
|
+
* runner 失败规则 —— 同样按后端分组。
|
|
1225
|
+
*
|
|
1226
|
+
* 致命特征都锚在**行首的后端自称**上(`sandbox-exec:` / `bwrap:`):
|
|
1227
|
+
* 那是 runner 自己说话时的形状,而被它包起来的命令说话时不带这个前缀。
|
|
1228
|
+
* 不这么锚的话,一条 `echo "bwrap: 出错了"` 就能把自己伪装成 runner 失败。
|
|
1229
|
+
*
|
|
1230
|
+
* `none` 一条规则都没有,理由同 {@link DENIAL_SIGNATURES}:没有 runner,
|
|
1231
|
+
* 就没有 runner 失败。
|
|
1232
|
+
*/
|
|
1233
|
+
declare const RUNNER_FAILURE_RULES: Record<IsolationBackend, RunnerFailureRules>;
|
|
1234
|
+
/**
|
|
1235
|
+
* 分类一次失败。
|
|
1236
|
+
*
|
|
1237
|
+
* 判定顺序是**先 runner 失败、后沙箱拒绝**,这个顺序有判据:runner 挂掉时
|
|
1238
|
+
* 命令根本没跑,所以它的 stderr 里不可能有命令自己产生的拒绝;反过来
|
|
1239
|
+
* runner 的错误消息里倒是可能出现 `Operation not permitted`
|
|
1240
|
+
* (`sandbox_apply` 失败就是这句),先判拒绝会把它报成「命令被挡了」——
|
|
1241
|
+
* 而那时真正该说的是「命令没有执行」。
|
|
1242
|
+
*
|
|
1243
|
+
* @param exitCode 子进程退出码。`-1` 表示被我们自己杀掉(超时 / 中断)
|
|
1244
|
+
* @param stderr 原始 stderr,**不做任何改写**
|
|
1245
|
+
*/
|
|
1246
|
+
declare function classifyFailure(input: {
|
|
1247
|
+
exitCode: number;
|
|
1248
|
+
stderr: string;
|
|
1249
|
+
denialSignatures: readonly RegExp[];
|
|
1250
|
+
runnerFailureRules: RunnerFailureRules;
|
|
1251
|
+
}): FailureVerdict;
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* 沙箱模式词汇 + 按调用带的策略 —— 方案 46 PR-1。
|
|
1255
|
+
*
|
|
1256
|
+
* 这个文件回答三个问题:**用什么词说边界**({@link SandboxMode})、
|
|
1257
|
+
* **这一次的边界是什么**({@link SandboxPolicy})、
|
|
1258
|
+
* **包完之后有多硬**({@link SandboxEnforcement})。
|
|
1259
|
+
*
|
|
1260
|
+
* 和 [backend.ts](./backend.js) 的 `isolate()` 是两个接缝,并存不是重复:
|
|
1261
|
+
*
|
|
1262
|
+
* | 接缝 | 调用方 | 形状 | 说得出强制完整度吗 |
|
|
1263
|
+
* | ----------- | ------------- | -------------------- | ------------------ |
|
|
1264
|
+
* | `isolate()` | `code_exec` | 三个散参数 | 说不出(只有有/无)|
|
|
1265
|
+
* | `confine()` | `terminal` | 一个 policy 对象 | 说得出 |
|
|
1266
|
+
*
|
|
1267
|
+
* 新接缝按调用带一个 policy 对象而不是三个散参数,是**刻意给将来留的形状**
|
|
1268
|
+
* (方案 46 §九最后一条):今天只有一个消费者一种边界,但第二个消费者进来那天,
|
|
1269
|
+
* 要加的是 policy 上的一个字段,不是所有调用点的一个新参数。
|
|
1270
|
+
*/
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* 文件效果策略。**只管文件**——网络和进程可见性不在这套词汇表里,
|
|
1274
|
+
* 它们各自由别的东西管(网络:{@link SandboxPolicy.allowNetwork};
|
|
1275
|
+
* 进程:**没人管**,沙箱里的命令能 `ps` 看见主机上的其他进程,如实说明)。
|
|
1276
|
+
*
|
|
1277
|
+
* 而且「只管文件」还要再收一句:**只管写入**。读取限制不是这套词汇表承诺的
|
|
1278
|
+
* 东西 —— `terminal` 那一档就是读取全开的,判据在
|
|
1279
|
+
* [seatbelt.ts](./seatbelt.js) 的 `SeatbeltOptions.reads` 上(一句话:
|
|
1280
|
+
* 上读白名单会让 `git push` 和 `pnpm install` 一起废掉)。
|
|
1281
|
+
*
|
|
1282
|
+
* 三个名字**一字不改地抄 Codex**(`sandbox_mode: read-only | workspace-write |
|
|
1283
|
+
* danger-full-access`)。理由不是省事,是**用户认得**:一个用过 Codex 的人
|
|
1284
|
+
* 不用重新学一套词。Codex 是 Rust,这里是重写不是衍生,所以不加
|
|
1285
|
+
* `SPDX-License-Identifier` 头(CLAUDE.md 铁律 11/12);
|
|
1286
|
+
* 逐字借来的是**这三个词**,出处就记在这一段里。
|
|
1287
|
+
*/
|
|
1288
|
+
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
1289
|
+
/**
|
|
1290
|
+
* 这台机器上的强制完整度。
|
|
1291
|
+
*
|
|
1292
|
+
* `partial` 意思是**有后端在跑,但它管不住这个模式承诺的全部文件效果** ——
|
|
1293
|
+
* 需要绝对边界的调用方不许把它当 `full`。
|
|
1294
|
+
*
|
|
1295
|
+
* 今天会报 `partial` 的只有一处,就在 {@link enforcementFor} 里逐条列着。
|
|
1296
|
+
* 将来的两处(Windows 受限令牌的 Everyone SID / 硬链接,老内核的 Landlock ABI)
|
|
1297
|
+
* 到时候加在同一个地方 —— 别让「哪些情况只是 partial」散成几处。
|
|
1298
|
+
*/
|
|
1299
|
+
type SandboxEnforcement = 'full' | 'partial';
|
|
1300
|
+
/**
|
|
1301
|
+
* 一次调用的沙箱边界。
|
|
1302
|
+
*
|
|
1303
|
+
* 每次调用带全套(而不是在模块里存一份全局配置),为的是两件今天还没有、
|
|
1304
|
+
* 但形状必须留好的事:**两个消费者同一瞬间用不同边界**,以及
|
|
1305
|
+
* **用户批准之后的升权重试是一次新调用**,不是去改一个全局开关。
|
|
1306
|
+
*/
|
|
1307
|
+
interface SandboxPolicy {
|
|
1308
|
+
mode: SandboxMode;
|
|
1309
|
+
/**
|
|
1310
|
+
* 工作区主根。`workspace-write` 下它是可写的那一个;
|
|
1311
|
+
* 不给(宿主没绑工作区)时可写集合里就只剩临时目录和缓存目录。
|
|
1312
|
+
*/
|
|
1313
|
+
workspaceRoot?: string;
|
|
1314
|
+
/** 额外的工作区根(`--add-dir`)。和主根同等对待 */
|
|
1315
|
+
extraRoots?: readonly string[];
|
|
1316
|
+
/**
|
|
1317
|
+
* 是否允许网络。
|
|
1318
|
+
*
|
|
1319
|
+
* ⚠️ **`terminal` 恒传 true**,这是方案 46 §九明确不做的一条:
|
|
1320
|
+
* `terminal` 的典型用途(`pnpm install`、`git push`)本来就要网络,
|
|
1321
|
+
* 一个默认断网的 `terminal` 会让模型每次都撞墙,然后学会绕开这个工具。
|
|
1322
|
+
*/
|
|
1323
|
+
allowNetwork: boolean;
|
|
1324
|
+
/** 子进程工作目录(部分后端要显式指定) */
|
|
1325
|
+
cwd?: string;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* 权限五级 → 沙箱模式。
|
|
1329
|
+
*
|
|
1330
|
+
* **保持一维**(2026-08-14 拍板,方案 46 §2.2):五级推导出沙箱模式,
|
|
1331
|
+
* 用户不多学一个概念。
|
|
1332
|
+
*
|
|
1333
|
+
* | 权限级别 | 沙箱模式 | 判据 |
|
|
1334
|
+
* | ------------- | -------------------- | ------------------------------------------------ |
|
|
1335
|
+
* | `plan` | `read-only` | 它已经是「只读模式」,沙箱把这句话变成 OS 强制的 |
|
|
1336
|
+
* | `auto` | `workspace-write` | 「工作区内自动批准」的边界就是工作区 |
|
|
1337
|
+
* | `default` | `workspace-write` | 「要不要问你」和「能写到哪」是两个正交问题 |
|
|
1338
|
+
* | `acceptEdits` | `workspace-write` | 同上 |
|
|
1339
|
+
* | `bypass` | `danger-full-access` | 「允许一切」还带着沙箱就是名不副实 |
|
|
1340
|
+
*
|
|
1341
|
+
* ⚠️ **`bypass` 那一格是唯一有争议的。** 另一个选项是「`bypass` 也保持
|
|
1342
|
+
* `workspace-write`,想真的全开得再显式配一个字段」。选现在这个的理由:
|
|
1343
|
+
* `bypass` 的文档承诺是「允许一切」,如果它悄悄还带着文件系统限制,症状就是
|
|
1344
|
+
* 「我明明开了 bypass,为什么写 `/etc/hosts` 还是失败」—— 而这种
|
|
1345
|
+
* **说了全开但实际没全开**的谎比不做沙箱更糟。将来要收紧的话,正确的做法是
|
|
1346
|
+
* 改 `bypass` 的文档承诺,不是让它偷偷带限制。
|
|
1347
|
+
*
|
|
1348
|
+
* `bypass` 下**危险命令表照旧拦**(方案 46 §七):沙箱这一层整个让开之后,
|
|
1349
|
+
* 那张表是唯一还在岗的东西。
|
|
1350
|
+
*
|
|
1351
|
+
* ## ⚠️ 两处「刻意的第二份」,都不是抄漏了
|
|
1352
|
+
*
|
|
1353
|
+
* 一、**参数是 `string` 不是 `PermissionLevel`**:`PermissionLevel` 住在
|
|
1354
|
+
* protocol,而 infra 不许依赖 protocol(同 `i18n.ts` 的 `LANGS`)。
|
|
1355
|
+
* 五个字面量在这里等于抄了第二遍,靠 `packages/core/__tests__/` 里那条
|
|
1356
|
+
* 用例锁着两边相等 —— core 同时够得着 protocol 和 infra。
|
|
1357
|
+
*
|
|
1358
|
+
* 二、**这段推导本该住在 `core/src/permission/by-level.ts` 旁边**
|
|
1359
|
+
* (方案 46 §2.2 逐字写着「那是权限系统的知识,plugin 只是消费它」),
|
|
1360
|
+
* 而它今天在 infra,因为**消费者是 `plugin-terminal`,够不着 core**。
|
|
1361
|
+
* ~~📌 **交给收账的人**~~ ✅ **2026-08-16 合并时收掉了**:`by-level.ts` 那天要用
|
|
1362
|
+
* 沙箱模式时,调这个函数,别在那边长出第二张表 —— 两张表分叉的表现是
|
|
1363
|
+
* 「审批说你在只读模式,沙箱却让你写进去了」,而两边各自都自洽。
|
|
1364
|
+
* **收成的是那边文件头上的一段同样的话**(代码一行没动,今天 `by-level.ts`
|
|
1365
|
+
* 还不需要沙箱模式):这张 📌 挂在这儿,而将来动手的人打开的是那个文件。
|
|
1366
|
+
*/
|
|
1367
|
+
declare function sandboxModeForLevel(level: string): SandboxMode;
|
|
1368
|
+
/**
|
|
1369
|
+
* `workspace-write` 下额外放开的工具链缓存目录(相对 `$HOME`)。
|
|
1370
|
+
*
|
|
1371
|
+
* ⚠️ **这张表是模式定义的一部分,不是漏出去的口子。** 一条 `pnpm test` 要往
|
|
1372
|
+
* `~/.cache`、`~/Library/Caches`、`~/.npm/_logs` 里写东西;不放开的结果不是
|
|
1373
|
+
* 「更安全」,是**用户发现终端跑不了测试,然后把整个沙箱关掉** ——
|
|
1374
|
+
* 那才是真正的安全损失。
|
|
1375
|
+
*
|
|
1376
|
+
* 判据是「这里面装的是可重建的构建产物」。所以 `~/.ssh`、`~/.aws`、`~/.gnupg`
|
|
1377
|
+
* 这类**不在**表里 —— 它们能读(读取不设限)但写不进去。
|
|
1378
|
+
*
|
|
1379
|
+
* 逐条列出来而不是放开整个 `$HOME`:`epoch doctor` 会把这张表原样打出来,
|
|
1380
|
+
* 用户看得见自己批准的是什么。
|
|
1381
|
+
*/
|
|
1382
|
+
declare const TOOLCHAIN_CACHE_DIRS: readonly string[];
|
|
1383
|
+
/** 包好之后的样子。`confined: false` 时**不带 argv** —— 调用方拿原样那份去跑 */
|
|
1384
|
+
type Confinement = {
|
|
1385
|
+
confined: true;
|
|
1386
|
+
command: string;
|
|
1387
|
+
args: string[];
|
|
1388
|
+
backend: IsolationBackend;
|
|
1389
|
+
mode: SandboxMode;
|
|
1390
|
+
enforcement: SandboxEnforcement;
|
|
1391
|
+
/** 这一次实际可写的目录,已解析成绝对路径。`epoch doctor` 原样打它 */
|
|
1392
|
+
writableDirs: readonly string[];
|
|
1393
|
+
/** **只有当前后端那一组**,消费者不许跨后端取并集(见 classify.ts 文件头)*/
|
|
1394
|
+
denialSignatures: readonly RegExp[];
|
|
1395
|
+
runnerFailureRules: RunnerFailureRules;
|
|
1396
|
+
} | {
|
|
1397
|
+
confined: false;
|
|
1398
|
+
backend: IsolationBackend;
|
|
1399
|
+
mode: SandboxMode;
|
|
1400
|
+
/**
|
|
1401
|
+
* `mode-disabled` = 这一档本来就不上沙箱(`danger-full-access`);
|
|
1402
|
+
* `no-backend` = 想上但这台机器上没有后端。
|
|
1403
|
+
*
|
|
1404
|
+
* 两档必须分开:前者是**用户自己选的**,后者是**平台限制**,
|
|
1405
|
+
* 而它们在界面上是两句不同的话(一句「你开了 bypass」,
|
|
1406
|
+
* 一句「这个平台没有 OS 级隔离」)。合成一个 `false` 的话,
|
|
1407
|
+
* Windows 用户会以为是自己把沙箱关了。
|
|
1408
|
+
*/
|
|
1409
|
+
reason: 'mode-disabled' | 'no-backend';
|
|
1410
|
+
};
|
|
1411
|
+
/**
|
|
1412
|
+
* 把一条命令包进当前平台的沙箱。
|
|
1413
|
+
*
|
|
1414
|
+
* @param command 可执行文件(通常是 `shellSpawnArgs()` 算出来的那个 shell)
|
|
1415
|
+
* @param args 它的参数
|
|
1416
|
+
* @returns 永远返回一个 {@link Confinement},**不返回 null** ——
|
|
1417
|
+
* 「没包上」也是一个要说出口的结论,而 `null` 让调用方很容易忘了说
|
|
1418
|
+
*/
|
|
1419
|
+
declare function confine(command: string, args: string[], policy: SandboxPolicy): Confinement;
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* macOS Seatbelt(sandbox-exec)隔离层 [Codex]。
|
|
1423
|
+
*
|
|
1424
|
+
* 提供**真正的 OS 级**限制,而不只是「换个 cwd 的子进程」:
|
|
1425
|
+
* 1. 写入:默认全禁,只放开沙箱目录和临时目录
|
|
1426
|
+
* 2. 网络:默认全禁(allowNetwork 显式打开)
|
|
1427
|
+
* 3. 读取:**默认全禁**,只放开系统库 / 解释器 / 沙箱目录 / 工作区
|
|
1428
|
+
*
|
|
1429
|
+
* 关于读取的取舍:整体 `(deny default)` 是不可行的(Node 直接 SIGABRT,
|
|
1430
|
+
* 要枚举 mach port / iokit / sysctl 一大堆条目,换台机器就崩)。
|
|
1431
|
+
* 但只对**文件读取**做 deny-default 是可行的,实测 Node 和 Python 都能正常跑。
|
|
1432
|
+
* 所以这里是白名单而不是黑名单——不再依赖「凭证目录列举得够全」。
|
|
1433
|
+
*
|
|
1434
|
+
* 这套组合的实际强度(三项都是 OS 强制):
|
|
1435
|
+
* - 改不了沙箱外的任何文件 ✅
|
|
1436
|
+
* - 传不出去数据 ✅(默认禁网)
|
|
1437
|
+
* - 读不到 $HOME 下的任何东西 ✅(含 ~/.ssh、keychain、浏览器数据)
|
|
1438
|
+
*
|
|
1439
|
+
* 平台现状(别高估):
|
|
1440
|
+
* - macOS:本模块生效
|
|
1441
|
+
* - Linux / Windows:**没有等价实现**,isolate() 返回 null,
|
|
1442
|
+
* 调用方会退回到「仅进程隔离」并且必须把这一点告诉用户
|
|
1443
|
+
*
|
|
1444
|
+
* `sandbox-exec` 在 macOS 上被标记为 deprecated 但至今仍可用(Chromium、
|
|
1445
|
+
* Codex 都在用)。若某天被移除,probeSeatbelt() 会探测失败并自动降级。
|
|
1446
|
+
*
|
|
1447
|
+
* ## ⚠️ 上面「这套组合的实际强度」三条**只对 `reads: 'whitelist'` 成立**
|
|
1448
|
+
*
|
|
1449
|
+
* 2026-08-16(方案 46)加了第二档 `reads: 'unrestricted'`,`terminal` 走那一档:
|
|
1450
|
+
* 写入边界一字不改,读取和网络全开。也就是说那一档买到的只有第一条 ✅,
|
|
1451
|
+
* 后两条**不成立**。哪一档在跑由 [policy.ts](./policy.js) 的 `confine()` 定,
|
|
1452
|
+
* 判据写在 {@link SeatbeltOptions.reads} 上。
|
|
1453
|
+
*
|
|
1454
|
+
* 这个文件 2026-08-16 从 `core/src/sandbox/` 搬来 infra,判据见
|
|
1455
|
+
* [backend.ts](./backend.js) 的文件头(一句话:消费者不止 core 一个了)。
|
|
1456
|
+
*/
|
|
1457
|
+
interface SeatbeltOptions {
|
|
1458
|
+
/** 允许读写的目录(通常是沙箱工作目录 + 临时目录) */
|
|
1459
|
+
writableDirs: string[];
|
|
1460
|
+
/**
|
|
1461
|
+
* 额外必须可读的目录(解释器所在路径等)。
|
|
1462
|
+
* 会排在读黑名单**之后**,确保不会被黑名单误伤。
|
|
1463
|
+
*/
|
|
1464
|
+
readableDirs?: string[];
|
|
1465
|
+
/** 是否允许网络访问 */
|
|
1466
|
+
allowNetwork: boolean;
|
|
1467
|
+
/**
|
|
1468
|
+
* 读取策略。**默认 `whitelist`**,与 2026-08-16 之前逐字节相同。
|
|
1469
|
+
*
|
|
1470
|
+
* ## `unrestricted` 是给 `terminal` 的,判据不是「懒得列」
|
|
1471
|
+
*
|
|
1472
|
+
* 沙箱模式**只管写入**([policy.ts](./policy.js) 的 `SandboxMode` 逐字写着
|
|
1473
|
+
* 这一条)。给 `terminal` 上读白名单会当场撞死两类日常命令,而且**撞得静悄悄**:
|
|
1474
|
+
*
|
|
1475
|
+
* - `git push` 要读 `~/.ssh`,`git commit` 要读 `~/.gitconfig`
|
|
1476
|
+
* - `pnpm install` 要读 `~/.npmrc`(私有 registry 的 token 就在里面)
|
|
1477
|
+
*
|
|
1478
|
+
* 这两处正好都在 {@link SENSITIVE_HOME_PATHS} 的兜底 deny 里 —— 也就是说
|
|
1479
|
+
* 「照 `code_exec` 那份 profile 抄一遍」的结果是终端里 git 和 pnpm 一起废掉。
|
|
1480
|
+
*
|
|
1481
|
+
* 所以这一档**如实地什么读取限制都不加**,包括不加兜底 deny。它买到的是
|
|
1482
|
+
* OS 强制的写入边界,买不到读取保密性 —— 而后者从来不是这个模式承诺的东西。
|
|
1483
|
+
*/
|
|
1484
|
+
reads?: 'whitelist' | 'unrestricted';
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* 生成 Seatbelt profile。
|
|
1488
|
+
*
|
|
1489
|
+
* 规则顺序有讲究——Seatbelt 是**后匹配优先**:
|
|
1490
|
+
* 1. (allow default) 非文件操作放行(否则 Node 起不来)
|
|
1491
|
+
* 2. (deny file-read*) + allow 读取白名单(`reads: 'unrestricted'` 时整段换成一句 allow)
|
|
1492
|
+
* 3. (deny file-write*) + allow 写入白名单
|
|
1493
|
+
* 4. (deny file-read* 敏感目录) 兜底,压在最后所以必然生效
|
|
1494
|
+
* 5. 网络
|
|
1495
|
+
*
|
|
1496
|
+
* ⚠️ **写入那一段在两档下逐字相同**,这是刻意的:`reads` 换档只改读取,
|
|
1497
|
+
* 一个「换成 terminal 那档之后写入边界也松了」的实现会让 `enforcement`
|
|
1498
|
+
* 报的 `full` 变成假话,而那正是这一层唯一不能出的谎。
|
|
1499
|
+
*/
|
|
1500
|
+
declare function buildProfile(opts: SeatbeltOptions): string;
|
|
1501
|
+
/** sandbox-exec 是否真的可用(只探测一次,结果缓存) */
|
|
1502
|
+
declare function probeSeatbelt(): boolean;
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Linux 隔离层 —— bubblewrap(bwrap)。
|
|
1506
|
+
*
|
|
1507
|
+
* 目标是给出与 macOS Seatbelt **对等**的保证:
|
|
1508
|
+
* 1. 写入:只有沙箱目录和临时目录可写,其余整个文件系统只读
|
|
1509
|
+
* 2. 读取:$HOME 被 tmpfs 盖住(看不到用户文件),系统库和工作区可读
|
|
1510
|
+
* 3. 网络:`--unshare-net` 直接断网(比 Seatbelt 的 deny 更硬)
|
|
1511
|
+
* 4. 进程:`--unshare-pid --die-with-parent` 防止留下孤儿进程
|
|
1512
|
+
*
|
|
1513
|
+
* 为什么选 bwrap 而不是 seccomp / 手写 namespace:
|
|
1514
|
+
* - bwrap 用非特权用户命名空间,不需要 root、不需要装 daemon
|
|
1515
|
+
* - Flatpak 在用,主流发行版都有包(`bubblewrap`)
|
|
1516
|
+
* - seccomp 只能过滤 syscall,做不了「$HOME 不可见」这种文件系统视图
|
|
1517
|
+
*
|
|
1518
|
+
* ⚠️ 未在本机验证:开发机是 macOS,下面的参数构造有单元测试覆盖,
|
|
1519
|
+
* 但**运行时行为没有在真实 Linux 上跑过**。probeBubblewrap() 会先探测
|
|
1520
|
+
* bwrap 是否真的可用(跑一个最小命令),探测失败就退回仅进程隔离,
|
|
1521
|
+
* 所以最坏情况是「没拿到 OS 级隔离」而不是「崩了」。
|
|
1522
|
+
*/
|
|
1523
|
+
interface BubblewrapOptions {
|
|
1524
|
+
/** 可读写的目录 */
|
|
1525
|
+
writableDirs: string[];
|
|
1526
|
+
/** 只读可见的目录(工作区、解释器等) */
|
|
1527
|
+
readableDirs?: string[];
|
|
1528
|
+
/** 是否允许网络 */
|
|
1529
|
+
allowNetwork: boolean;
|
|
1530
|
+
/** 子进程的工作目录 */
|
|
1531
|
+
cwd?: string;
|
|
1532
|
+
/**
|
|
1533
|
+
* 读取策略,语义与 Seatbelt 那边逐字相同(判据见
|
|
1534
|
+
* [seatbelt.ts](./seatbelt.js) 的 `SeatbeltOptions.reads`)。
|
|
1535
|
+
*
|
|
1536
|
+
* 在这个后端上它只有一个作用:**`unrestricted` 时不拿 tmpfs 盖 `$HOME`**。
|
|
1537
|
+
* 盖住的话终端里 `git` 读不到 `~/.gitconfig`、`pnpm` 读不到 `~/.npmrc`,
|
|
1538
|
+
* 而只读根本来就已经保证了「看得见 ≠ 改得了」。
|
|
1539
|
+
*
|
|
1540
|
+
* ⚠️ 与本文件其余部分同样**未在真实 Linux 上跑过**(见文件头)。加这一档不是
|
|
1541
|
+
* 为 Linux 补功能,是不让两个后端在同一个 `mode` 下给出不同的边界 ——
|
|
1542
|
+
* 那种不一致是 `enforcement` 报 `full` 时最难发现的一种假话。
|
|
1543
|
+
*/
|
|
1544
|
+
reads?: 'whitelist' | 'unrestricted';
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* 探测 bwrap 是否可用。
|
|
1548
|
+
*
|
|
1549
|
+
* 只看文件存在是不够的:容器里常常有 bwrap 但内核禁用了非特权用户命名空间
|
|
1550
|
+
* (`kernel.unprivileged_userns_clone=0`),那时 bwrap 会直接报错。
|
|
1551
|
+
* 所以真的跑一次最小命令。
|
|
1552
|
+
*/
|
|
1553
|
+
declare function probeBubblewrap(): string | null;
|
|
1554
|
+
/**
|
|
1555
|
+
* 构建 bwrap 参数。
|
|
1556
|
+
*
|
|
1557
|
+
* 顺序很重要:bwrap 按参数顺序依次搭建挂载视图,**后面的覆盖前面的**。
|
|
1558
|
+
* 所以先 `--ro-bind / /` 铺一层只读根,再用 tmpfs 盖掉 $HOME,
|
|
1559
|
+
* 最后把确实需要的目录重新 bind 回来。
|
|
1560
|
+
*
|
|
1561
|
+
* 导出成纯函数,方便在非 Linux 机器上也能单元测试参数是否正确。
|
|
1562
|
+
*/
|
|
1563
|
+
declare function buildBwrapArgs(command: string, commandArgs: string[], opts: BubblewrapOptions): string[];
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* 长期子进程 —— 启动、登记、回收(方案 36)。
|
|
1567
|
+
*
|
|
1568
|
+
* 「长期」的意思是**活得比一次工具调用长**:后台的 `pnpm dev`、
|
|
1569
|
+
* 将来方案 38 的 language server。它们和 `runCommand` 那种「起来跑完就没了」的
|
|
1570
|
+
* 一次性命令有两点不同:
|
|
1571
|
+
*
|
|
1572
|
+
* 1. 没人在 await 它,所以**必须有一张表**记着,否则宿主退出时收不回来
|
|
1573
|
+
* 2. 输出没有接收者,所以**必须自己接住**,否则管道写满 64KB 之后子进程会阻塞
|
|
1574
|
+
*
|
|
1575
|
+
* 下沉到 infra 而不是留在 plugin-terminal,是因为 plugin 之间不许互相依赖,
|
|
1576
|
+
* 而方案 38 的 language server 要的是同一套东西。
|
|
1577
|
+
*
|
|
1578
|
+
* ## `detached: false`,和前台命令同一个决定
|
|
1579
|
+
*
|
|
1580
|
+
* [exec.ts 的 `killChildTree`](../../plugins/plugin-terminal/src/exec.ts) 那段注释
|
|
1581
|
+
* 把理由写透了:`epoch` 装了 `installSignalHandlers`,Ctrl+C 时那个监听器直接
|
|
1582
|
+
* `dispose(); process.exit(0)`,**没有任何一行我们的清理代码有机会跑完**。
|
|
1583
|
+
* 现在能不残留靠的是内核 —— 子进程和我们在同一个前台进程组,终端把 SIGINT
|
|
1584
|
+
* 发给整组。
|
|
1585
|
+
*
|
|
1586
|
+
* 这条对长期子进程**更重要**,不是更不重要:它们活得久,撞上 Ctrl+C 的概率
|
|
1587
|
+
* 高得多。代价是「后台任务不能活过 epoch 进程」——这是刻意的,写进了工具描述。
|
|
1588
|
+
*
|
|
1589
|
+
* > 改造前 `runBackground` 用的是 `detached: true` + `unref()`,恰好是反的:
|
|
1590
|
+
* > Ctrl+C 到不了它们,`kill -9 epoch` 之后用户机器上会留下一堆 `pnpm dev`。
|
|
1591
|
+
*/
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* 拿 `process.execPath` 当 node 使的地方,子进程**必须**带上的环境变量。
|
|
1595
|
+
*
|
|
1596
|
+
* ## 为什么需要
|
|
1597
|
+
*
|
|
1598
|
+
* CLI 用户那里 `process.execPath` 就是 node,这一格是空操作。但嵌进 Electron
|
|
1599
|
+
* 主进程的宿主那里它是**宿主自己的可执行文件**:spawn 它并传一个 `.js` 路径
|
|
1600
|
+
* 不会跑成 Node —— Electron 会去启动宿主自己(打包后是第二个 app 实例,
|
|
1601
|
+
* macOS 上还多一个 Dock 图标;开发期是拿那个 `.js` 当 app 入口去加载),
|
|
1602
|
+
* 我们要跑的那段代码一行都不执行。`ELECTRON_RUN_AS_NODE=1` 让那个二进制退化成
|
|
1603
|
+
* 纯 Node,于是嵌入宿主里这条路和 CLI 完全一致,**而且不要求用户机器上装过 Node**
|
|
1604
|
+
* (见 [EMBEDDING.md](../../../docs/EMBEDDING.md) 的「用户机器上没装 Node」一节)。
|
|
1605
|
+
*
|
|
1606
|
+
* ## 为什么无条件设,而不是先探一下自己在不在 Electron 里
|
|
1607
|
+
*
|
|
1608
|
+
* 真 node 压根不读这个变量(它是 Electron 在启动早期自己查的),所以设了零成本。
|
|
1609
|
+
* 而探测那条路要赌 `process.versions.electron` 在 run-as-node 模式下还在不在 ——
|
|
1610
|
+
* 这一点本机验不了(`electron@43` 的 npm 包不下载二进制),而**赌错的那一半恰好
|
|
1611
|
+
* 就是 bug 还在的那一半**:VS Code 的扩展宿主就是「本来就跑在 run-as-node 里」
|
|
1612
|
+
* 的形态,探测漏判它,`code_exec` 就成了「跑一段 JS 弹出一个 VS Code」。
|
|
1613
|
+
*
|
|
1614
|
+
* ## 住在 infra 而不是各自包里
|
|
1615
|
+
*
|
|
1616
|
+
* 两处要用,而它们分在 `core`(`code_exec` 的沙箱)和 `plugin-lsp`
|
|
1617
|
+
* (项目本地那一档 language server)—— plugin 够不着 core,共用的东西只能下沉。
|
|
1618
|
+
* 全仓「哪些地方 spawn `process.execPath`」由根 `__tests__/exec-path-as-node.test.ts`
|
|
1619
|
+
* 数着:新开一处而没带这个变量,那条用例会红。
|
|
1620
|
+
*
|
|
1621
|
+
* ⚠️ **不能靠继承。** 沙箱那份 env 是白名单重建的(别把 API key 漏给被执行的
|
|
1622
|
+
* 代码),宿主在自己进程上设过的 `ELECTRON_RUN_AS_NODE` 到不了子进程;
|
|
1623
|
+
* 而 LSP 那条虽然继承全量 env,Electron 主进程里这个变量本来就是空的。
|
|
1624
|
+
* 所以「让宿主自己设一下」两条路都走不通。
|
|
1625
|
+
*/
|
|
1626
|
+
declare const EXEC_PATH_AS_NODE_ENV: Readonly<Record<string, string>>;
|
|
1627
|
+
/** 一个被登记的长期子进程 */
|
|
1628
|
+
interface TrackedProcess {
|
|
1629
|
+
readonly pid: number;
|
|
1630
|
+
readonly child: ChildProcess;
|
|
1631
|
+
}
|
|
1632
|
+
interface StartLongLivedOptions {
|
|
1633
|
+
file: string;
|
|
1634
|
+
args: readonly string[];
|
|
1635
|
+
cwd?: string;
|
|
1636
|
+
/** 透传给 `spawn`(Windows 的 `windowsVerbatimArguments` 之类) */
|
|
1637
|
+
spawnOptions?: SpawnOptions;
|
|
1638
|
+
/** stdout / stderr 各来一段就调一次。**不给的话输出被丢弃但管道仍然被读空** */
|
|
1639
|
+
onOutput?: (chunk: string, stream: 'stdout' | 'stderr') => void;
|
|
1640
|
+
/** 进程结束时调一次 */
|
|
1641
|
+
onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* 起一个长期子进程并登记。
|
|
1645
|
+
*
|
|
1646
|
+
* `stdio` 一律是 `pipe`:**不能用 `'ignore'`** —— 那样 `task_output` 无从取起;
|
|
1647
|
+
* 也**不能用 `'inherit'`** —— 后台任务的输出直接打到用户终端上会把 TUI 冲烂。
|
|
1648
|
+
* 管道必须被读空,否则子进程写满内核缓冲区(Linux 上 64KB)之后会永久阻塞在
|
|
1649
|
+
* `write()` 上,表现成「后台任务莫名其妙不动了」。所以即使调用方不给
|
|
1650
|
+
* `onOutput`,这里也照样订阅 `data` 事件。
|
|
1651
|
+
*/
|
|
1652
|
+
declare function startLongLivedProcess(opts: StartLongLivedOptions): TrackedProcess;
|
|
1653
|
+
/**
|
|
1654
|
+
* 杀掉一个登记过的进程**及其整棵树**。
|
|
1655
|
+
*
|
|
1656
|
+
* 杀树而不是杀组:`detached: false` 意味着我们和它在同一个进程组,
|
|
1657
|
+
* `kill(-pgid)` 会把**我们自己**也打进去。树遍历(`pgrep -P` / `taskkill /t`)
|
|
1658
|
+
* 是这个形态下唯一正确的做法 —— 这正是 `killProcessTree` 存在的理由。
|
|
1659
|
+
*/
|
|
1660
|
+
declare function killTrackedProcess(pid: number): Promise<void>;
|
|
1661
|
+
/** 还活着的登记进程数 */
|
|
1662
|
+
declare function trackedProcessCount(): number;
|
|
1663
|
+
/** 全部收掉。宿主 `dispose()` 时调 */
|
|
1664
|
+
declare function killAllTrackedProcesses(): Promise<void>;
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Shell 命令解析 —— 把一条命令串切成「若干段,每段一个 root + 一串词」。
|
|
1668
|
+
*
|
|
1669
|
+
* 存在的理由是 [command-safety.ts](./command-safety.ts) 原来那条路走死了:
|
|
1670
|
+
* 它先 `replace(/["']/g, '')` 剥掉引号再拿正则打整串,于是
|
|
1671
|
+
*
|
|
1672
|
+
* git commit -m "fix: remove sudo from docs" → 被判成「提权执行」
|
|
1673
|
+
* echo "just documenting rm -rf / here" → 被判成「递归删除根目录」
|
|
1674
|
+
*
|
|
1675
|
+
* **引号是 shell 里唯一能区分「命令词」和「字符串字面量」的东西**,剥掉之后
|
|
1676
|
+
* 这两类就再也分不开了。所以危险判定必须建在分词结果上,不能建在原串上。
|
|
1677
|
+
*
|
|
1678
|
+
* ## 为什么不用 `shell-quote`
|
|
1679
|
+
*
|
|
1680
|
+
* 它是**方案 21** 里点名要引的库(那份 md 2026-08-08 按去留规则删了,
|
|
1681
|
+
* 而它**没有独立的验收记录** —— 判据当时就是搬进本文件这段头注释的),
|
|
1682
|
+
* gemini-cli 也在用。实测之后**否掉了**:它按 POSIX 规矩把反斜杠当转义符,
|
|
1683
|
+
* 于是
|
|
1684
|
+
*
|
|
1685
|
+
* C:\Windows\System32 → ["C:WindowsSystem32"]
|
|
1686
|
+
* del /f /s /q C:\ → ["del","/f","/s","/q","C:"]
|
|
1687
|
+
*
|
|
1688
|
+
* Windows 那张表整个建立在盘符和路径分隔符上,用它等于把 43 条既有用例全判错。
|
|
1689
|
+
* 它还有两个坑:引号不闭合时**静默吞掉**(`rm -rf "` → `["rm","-rf"]`,
|
|
1690
|
+
* 不抛错),以及默认会把 `$VAR` 展开成空串。
|
|
1691
|
+
*
|
|
1692
|
+
* 换句话说它是个 POSIX-only 的工具,而我们是 Windows + macOS 双平台。
|
|
1693
|
+
* 自己写一个带 flavor 的分词器只多了几十行,反而不用引依赖。
|
|
1694
|
+
*
|
|
1695
|
+
* ## 定位没变
|
|
1696
|
+
*
|
|
1697
|
+
* 这仍然是**启发式护栏的一部分**,不是沙箱、也不是完整的 shell 语法分析。
|
|
1698
|
+
* 它不处理 heredoc、不展开变量、不解析函数定义。遇到看不懂的东西
|
|
1699
|
+
* (引号不闭合、命令替换)它会**说出来**(`parseError` / `hasSubstitution`),
|
|
1700
|
+
* 由调用方升级成「要人确认」——这正是原来最缺的一环:以前看不懂就默认放行。
|
|
1701
|
+
*/
|
|
1702
|
+
/**
|
|
1703
|
+
* 分词口味。**不是平台**,是「这条命令要交给哪种 shell」。
|
|
1704
|
+
*
|
|
1705
|
+
* Windows 机器上跑 Git Bash 是 `posix`,跑 cmd / PowerShell 是 `windows`。
|
|
1706
|
+
* 两者的区别**只有一条**,但那一条很致命:**反斜杠是转义符还是路径分隔符**。
|
|
1707
|
+
* 引号(单双)、分隔符、重定向的处理两边一样。
|
|
1708
|
+
*/
|
|
1709
|
+
type ShellFlavor = 'posix' | 'windows';
|
|
1710
|
+
/** 一段命令:`a && b` 里的 `a` 或 `b` */
|
|
1711
|
+
interface CommandSegment {
|
|
1712
|
+
/**
|
|
1713
|
+
* 段内的命令名,已经做过三件事:剥掉 `FOO=1` 这类前缀赋值、
|
|
1714
|
+
* 取 basename(`/bin/rm` → `rm`)、转小写。
|
|
1715
|
+
*
|
|
1716
|
+
* 空串表示这一段没有可辨识的命令(纯重定向、语法碎片等)。
|
|
1717
|
+
*/
|
|
1718
|
+
root: string;
|
|
1719
|
+
/**
|
|
1720
|
+
* 词数组,**引号已解开**,`argv[0]` 是命令名原文(没取 basename、没转小写)。
|
|
1721
|
+
*
|
|
1722
|
+
* 危险判定只看 `root` 和**开关类词**(`-rf` / `--force`),
|
|
1723
|
+
* **不拿这里的自由文本去匹配危险模式** —— 那正是误杀的来源。
|
|
1724
|
+
*/
|
|
1725
|
+
argv: string[];
|
|
1726
|
+
/**
|
|
1727
|
+
* 段首的环境变量赋值前缀,如 `FOO=1 BAR=2 rm` 里的 `['FOO=1','BAR=2']`。
|
|
1728
|
+
*
|
|
1729
|
+
* 它们**不在 `argv` 里**(`argv[0]` 是真正的命令名),所以只看 argv 的调用方
|
|
1730
|
+
* 会把 `LD_PRELOAD=/evil.so pnpm test` 看成一条干净的 `pnpm test`。
|
|
1731
|
+
* 权限规则匹配就吃过这个亏,所以单独拎出来让调用方能显式拒绝。
|
|
1732
|
+
*/
|
|
1733
|
+
assignments: string[];
|
|
1734
|
+
/** 这一段的原文(保留引号),报错和展示用 */
|
|
1735
|
+
raw: string;
|
|
1736
|
+
/** 这一段是不是被管道喂进来的(`curl x | sh` 的第二段为 true) */
|
|
1737
|
+
pipedInto: boolean;
|
|
1738
|
+
/** 段内的重定向目标,如 `> /dev/sda` 的 `/dev/sda` */
|
|
1739
|
+
redirects: string[];
|
|
1740
|
+
}
|
|
1741
|
+
interface ParsedCommand {
|
|
1742
|
+
segments: CommandSegment[];
|
|
1743
|
+
/**
|
|
1744
|
+
* 含命令替换(`$(...)` / 反引号 / `<(...)`)。
|
|
1745
|
+
*
|
|
1746
|
+
* 为真时**分词结果不可信** —— `$(echo rm) -rf /` 的 root 是一段替换而不是 `rm`。
|
|
1747
|
+
* 调用方应当据此升级成「要人确认」,而不是当成「没危险」。
|
|
1748
|
+
*/
|
|
1749
|
+
hasSubstitution: boolean;
|
|
1750
|
+
/**
|
|
1751
|
+
* 解析失败的原因;解析成功时为 `undefined`。
|
|
1752
|
+
*
|
|
1753
|
+
* **语义写死在这里:`parseError` 非空 = 我们判断不了 ≠ 没危险。**
|
|
1754
|
+
* 原来的实现遇到看不懂的一律返回「不危险」,方向是反的。
|
|
1755
|
+
*/
|
|
1756
|
+
parseError?: string;
|
|
1757
|
+
/** 剥掉 `bash -c "..."` / `cmd /c ...` 外壳之后真正要执行的串 */
|
|
1758
|
+
stripped: string;
|
|
1759
|
+
}
|
|
1760
|
+
declare function stripShellWrapper(command: string): string;
|
|
1761
|
+
/**
|
|
1762
|
+
* 解析一条命令。
|
|
1763
|
+
*
|
|
1764
|
+
* @param command 原始命令串
|
|
1765
|
+
* @param flavor 交给哪种 shell;不传按当前平台猜
|
|
1766
|
+
*/
|
|
1767
|
+
declare function parseShellCommand(command: string, flavor?: ShellFlavor): ParsedCommand;
|
|
1768
|
+
/**
|
|
1769
|
+
* 按空白切词,**引号感知**(方案 23 的 `$1` / `$2` 位置参数)。
|
|
1770
|
+
*
|
|
1771
|
+
* 和 `parseShellCommand` 共用同一个 `tokenize`,但**不做分段**:入参不是一条
|
|
1772
|
+
* 要执行的命令,是斜杠命令后面那串参数。`/foo a && b` 应该得到三个词
|
|
1773
|
+
* (`&&` 只是个普通实参),而不是两段命令 —— 所以分隔符和重定向符在这里
|
|
1774
|
+
* 按字面量原样收进结果。
|
|
1775
|
+
*
|
|
1776
|
+
* ⚠️ **结果与平台有关**,因为 `tokenize` 的转义规则与平台有关:POSIX 下
|
|
1777
|
+
* `\` 是转义符,Windows 下它是路径分隔符(`C:\Users` 必须原样留着)。
|
|
1778
|
+
* 这是刻意的 —— 用户敲的参数应该按他那台机器的 shell 习惯来断句。
|
|
1779
|
+
* 要确定性结果就显式传 `flavor`(用例都这么写)。
|
|
1780
|
+
*
|
|
1781
|
+
* @returns 词数组;引号不闭合时返回 `null`(和 `parseShellCommand` 同一语义:
|
|
1782
|
+
* 空数组是「没有参数」,null 是「我读不懂」)
|
|
1783
|
+
*/
|
|
1784
|
+
declare function splitWords(input: string, flavor?: ShellFlavor): string[] | null;
|
|
1785
|
+
/**
|
|
1786
|
+
* 命令是不是含有「会产生副作用的通道」——重定向 / 管道 / 串联 / 后台。
|
|
1787
|
+
*
|
|
1788
|
+
* `isReadOnlyCommand` 用它做第一道筛:`ls > /etc/passwd` 的 root 也是 `ls`,
|
|
1789
|
+
* 只看 root 会把它放行。
|
|
1790
|
+
*/
|
|
1791
|
+
declare function hasSideEffectChannel(parsed: ParsedCommand): boolean;
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* 工作区归属判定 —— **全仓唯一的实现处**(方案 29 §2.2 / 验收 #11)。
|
|
1795
|
+
*
|
|
1796
|
+
* 改造前这个判据有三份逐字重复的实现:core 的 `permission/by-level.ts`、
|
|
1797
|
+
* plugin-file 的 `utils.ts`、plugin-terminal 的 `index.ts`。三份当时都是对的,
|
|
1798
|
+
* 但 `--add-dir` 一进来就必须同时改三处 —— 漏一处的表现是「文件工具放行了、
|
|
1799
|
+
* 权限层却当成越界」或者反过来,而这两种现象都极难反推回「有第二份判定」。
|
|
1800
|
+
*
|
|
1801
|
+
* ## 为什么落在 infra 而不是方案写的 `core/src/workspace/`
|
|
1802
|
+
*
|
|
1803
|
+
* [check-layers](../../../scripts/check-layers.mjs) 的 `ALLOWED` 表里
|
|
1804
|
+
* `plugin-*` 只允许依赖 `protocol` + `infra`。判据放进 core,两个 plugin 就
|
|
1805
|
+
* 够不着它,「判定点唯一」当场落空 —— 而那正是本方案要买的东西。
|
|
1806
|
+
* 所以**纯判据**在这里,core 的 `Workspace`(多根 + 诊断措辞)是它上面的壳。
|
|
1807
|
+
*/
|
|
1808
|
+
/**
|
|
1809
|
+
* 目标在不在工作区内。
|
|
1810
|
+
*
|
|
1811
|
+
* @param target 要判定的路径,绝对或相对都行。**空串一律放行** —— 那是
|
|
1812
|
+
* 「这次操作没有路径目标」(纯命令)的表示法,按路径判它等于把所有命令
|
|
1813
|
+
* 都判成越界
|
|
1814
|
+
* @param workDir 主根。**相对路径只按它解析,不按额外根解析**:否则
|
|
1815
|
+
* `file_read('src/a.ts')` 会在每个额外目录里各找一遍,同一个相对路径
|
|
1816
|
+
* 有了多个答案,而模型无从知道命中的是哪一个
|
|
1817
|
+
* @param extraRoots `--add-dir` 加进来的额外根(方案 29 §2.2)。
|
|
1818
|
+
* 不给等于只有主根,行为与改动前逐字节相同
|
|
1819
|
+
*/
|
|
1820
|
+
declare function isInWorkspace(target: string, workDir: string, extraRoots?: readonly string[]): boolean;
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* 配置校验辅助 —— 让「配置写错」变成一条能读懂的错误,而不是静默走默认值。
|
|
1824
|
+
*
|
|
1825
|
+
* 现状(本文件只提供工具,改造由各自的调用方做):
|
|
1826
|
+
* - core/config/loader.ts 已换成 zod(方案 03)
|
|
1827
|
+
* - core/policy/loader.ts 自建 TOML 解析器 + zod 校验(方案 03)
|
|
1828
|
+
* - core/hook/config-loader.ts 已换成 zod(方案 03)
|
|
1829
|
+
* - plugin-mcp/registry.ts catch { return [] } —— mcp.json 写错一个字符,
|
|
1830
|
+
* 行为是「静默地没有 MCP」,不报错(方案 05 负责)
|
|
1831
|
+
*
|
|
1832
|
+
* 放在 infra 而不是各包自己引 zod:两份用法 = 两种错误格式,宿主没法统一展示。
|
|
1833
|
+
*/
|
|
1834
|
+
|
|
1835
|
+
interface ParseIssue {
|
|
1836
|
+
/** 出错字段的路径,用 `.` 连接(如 `provider.apiKey`);根层级为 `(root)` */
|
|
1837
|
+
path: string;
|
|
1838
|
+
message: string;
|
|
1839
|
+
}
|
|
1840
|
+
interface ParseOutcome<T> {
|
|
1841
|
+
/** 校验通过的值;失败时是调用方给的 fallback */
|
|
1842
|
+
value: T;
|
|
1843
|
+
/** 校验问题清单,空数组表示全通过 */
|
|
1844
|
+
issues: ParseIssue[];
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* 宽容校验:失败不抛,返回 fallback + 问题清单。
|
|
1848
|
+
*
|
|
1849
|
+
* 为什么不抛:单个配置文件写错不该让 agent 起不来(safeInit 模式)。
|
|
1850
|
+
* 但**必须**把 issues 交给调用方写进 diagnostics —— 静默吞掉等于没校验。
|
|
1851
|
+
*/
|
|
1852
|
+
declare function parseLenient<T>(schema: ZodType<T>, input: unknown, fallback: T): ParseOutcome<T>;
|
|
1853
|
+
/**
|
|
1854
|
+
* 严格校验:失败抛异常。用于「错了就不该继续」的场景。
|
|
1855
|
+
*
|
|
1856
|
+
* @param label 出现在错误信息开头的来源标识(文件路径 / 配置段名),
|
|
1857
|
+
* 没有它用户只知道「有个字段错了」却不知道该改哪个文件。
|
|
1858
|
+
*/
|
|
1859
|
+
declare function parseStrict<T>(schema: ZodType<T>, input: unknown, label: string): T;
|
|
1860
|
+
/**
|
|
1861
|
+
* issues → 可直接塞进 diagnostics 的单行文案。
|
|
1862
|
+
*
|
|
1863
|
+
* diagnostics 是「每个模块一行」的清单,多行文本在终端里会被缩进破坏,
|
|
1864
|
+
* 所以这里保证每条都是一行,并带上 label 说明是哪个文件的问题。
|
|
1865
|
+
*
|
|
1866
|
+
* @param label 来源标识,如 `Config` / `Policy rules.toml`
|
|
1867
|
+
* @param limit 最多展开几条,超出的折叠成一句「还有 N 处」
|
|
1868
|
+
*/
|
|
1869
|
+
declare function formatIssues(label: string, issues: readonly ParseIssue[], limit?: number): string[];
|
|
1870
|
+
/**
|
|
1871
|
+
* 同 `formatIssues`,但**不带模块前缀**。
|
|
1872
|
+
*
|
|
1873
|
+
* 给结构化诊断用(方案 17):那边的 `module` 是独立字段,前缀拼进 detail
|
|
1874
|
+
* 就成了 `Config: Config: xxx`。两个函数共用同一份截断规则,不是各写一遍。
|
|
1875
|
+
*/
|
|
1876
|
+
declare function issueDetails(issues: readonly ParseIssue[], limit?: number): string[];
|
|
1877
|
+
/**
|
|
1878
|
+
* 未知字段提示。
|
|
1879
|
+
*
|
|
1880
|
+
* 刻意**不**走 zod 的 `.strict()`:用户配了个我们不认识的键(拼错、或是
|
|
1881
|
+
* 新版本才有的字段),不该让整份配置失效走 fallback。但也不能一声不吭——
|
|
1882
|
+
* 「配了没生效」是最难自查的一类问题。所以做成独立的提示型 issue:
|
|
1883
|
+
* 不影响解析结果,只在 diagnostics 里提一句。
|
|
1884
|
+
*
|
|
1885
|
+
* @param known 该层级认识的键名
|
|
1886
|
+
* @param input 原始对象;不是普通对象就返回空(类型错误由 schema 负责报)
|
|
1887
|
+
* @param prefix 路径前缀,嵌套段传 `budget` 这样的段名
|
|
1888
|
+
*/
|
|
1889
|
+
declare function unknownKeyIssues(known: readonly string[], input: unknown, prefix?: string): ParseIssue[];
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* 异步小工具 —— 装配层反复要用的两个模式。
|
|
1893
|
+
*
|
|
1894
|
+
* 原来这两个函数是 cli/bootstrap.ts 的私有函数。装配层要搬到 runtime 包,
|
|
1895
|
+
* 而这两个东西跟装配无关(core 的 MCP 超时、插件初始化也用得上),提到 infra。
|
|
1896
|
+
*/
|
|
1897
|
+
/**
|
|
1898
|
+
* `safeInit` 要往哪儿记结论。
|
|
1899
|
+
*
|
|
1900
|
+
* **这里刻意不 import protocol 的 `DiagnosticSink`** —— infra 是零内部依赖包
|
|
1901
|
+
* (见 CLAUDE.md 的架构分层表和 `scripts/check-layers.mjs` 的 ALLOWED 表),
|
|
1902
|
+
* 一旦依赖 protocol,`check:layers` 当场红。
|
|
1903
|
+
*
|
|
1904
|
+
* 所以这里只声明**用得到的那两个方法**,靠结构化类型对上:protocol 的
|
|
1905
|
+
* `DiagnosticSink` 天然满足它,调用方不需要做任何适配。多声明一个方法都是
|
|
1906
|
+
* 在给 infra 平白加约束。
|
|
1907
|
+
*/
|
|
1908
|
+
interface DiagnosticCollector {
|
|
1909
|
+
ok(module: string, detail?: string): void;
|
|
1910
|
+
failed(module: string, detail: string, code?: string): void;
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* 给 promise 加超时。超时返回兜底值,而不是抛异常。
|
|
1914
|
+
*
|
|
1915
|
+
* 用在「外部进程可能卡住,但不能因此起不来」的地方(MCP server 连接、
|
|
1916
|
+
* 模型列表拉取)。抛异常会让调用方被迫写 try/catch 再判断是超时还是真错。
|
|
1917
|
+
*/
|
|
1918
|
+
declare function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T>;
|
|
1919
|
+
/**
|
|
1920
|
+
* 模块安全初始化 [Hermes safeInit]。
|
|
1921
|
+
*
|
|
1922
|
+
* 单个模块失败不阻塞其他模块 —— agent 少一个能力也比起不来好。
|
|
1923
|
+
* 结论记进 sink,由宿主决定怎么呈现给用户。
|
|
1924
|
+
*
|
|
1925
|
+
* 失败带 `code: 'init-failed'`:宿主想「把所有起不来的模块列出来」时不该去
|
|
1926
|
+
* 匹配 detail 里的 `FAILED (…)` 这个前缀(方案 17)。
|
|
1927
|
+
*/
|
|
1928
|
+
declare function safeInit<T>(name: string, factory: () => T, diags: DiagnosticCollector): T | null;
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* 国际化的实现(方案 40 PR-1)—— catalog 加载、三层回落、语言解析。
|
|
1932
|
+
*
|
|
1933
|
+
* 值域(`Lang` / `LANGS` / `DEFAULT_LANG`)的**契约**在
|
|
1934
|
+
* [protocol/src/i18n.ts](../../protocol/src/i18n.ts),那边同时写着「为什么只有
|
|
1935
|
+
* 两个语言」。这里刻意再抄一份 `LANGS`,理由和 `platform.ts` 的 `SHELL_KINDS`
|
|
1936
|
+
* 完全相同:infra 是零内部依赖包(见 `scripts/check-layers.mjs` 的 ALLOWED 表),
|
|
1937
|
+
* import protocol 会让 `check:layers` 当场红。两份不许走散 —— 根
|
|
1938
|
+
* `__tests__/i18n-catalog.test.ts` 里钉着「逐项相等(含顺序)」。
|
|
1939
|
+
*
|
|
1940
|
+
* ## 三层回落,永不崩
|
|
1941
|
+
*
|
|
1942
|
+
* ```
|
|
1943
|
+
* 目标语言的 key → zh(DEFAULT_LANG)的 key → 返回 key 路径本身
|
|
1944
|
+
* ```
|
|
1945
|
+
*
|
|
1946
|
+
* 第三层是关键:**catalog 写坏了不能让 agent 起不来**。最坏情况用户看到
|
|
1947
|
+
* `permission.denied_dangerous` 这种字符串 —— 难看,但能跑。这和
|
|
1948
|
+
* [config/loader.ts](../../core/src/config/loader.ts) 的「单个配置文件写错
|
|
1949
|
+
* 不该让 agent 起不来」是同一条原则。
|
|
1950
|
+
*
|
|
1951
|
+
* ## catalog 坏了的诊断,自己也走这条回落链
|
|
1952
|
+
*
|
|
1953
|
+
* 下面 `i18nDiagnostics()` 报的那几句话,本身就是 catalog 里的 key。
|
|
1954
|
+
* 所以 `zh.yaml` 整份坏掉的时候,「zh.yaml 坏了」这句提示会退化成
|
|
1955
|
+
* `i18n.catalog_invalid` 这个 key 路径 —— 这不是 bug,正是第三层要保证的那件事:
|
|
1956
|
+
* 难看,但能跑,而且用户仍然看得出问题出在 i18n。
|
|
1957
|
+
*/
|
|
1958
|
+
/**
|
|
1959
|
+
* 支持的语言。**必须与 protocol 的 `LANGS` 逐项相等(含顺序)**,
|
|
1960
|
+
* 分层的代价,见本文件头。
|
|
1961
|
+
*/
|
|
1962
|
+
declare const LANGS: readonly ["zh", "en"];
|
|
1963
|
+
/** 支持的语言 */
|
|
1964
|
+
type Lang = (typeof LANGS)[number];
|
|
1965
|
+
/** 默认语言,同时是 catalog 基线和回落链的第二层 */
|
|
1966
|
+
declare const DEFAULT_LANG: Lang;
|
|
1967
|
+
/** 是不是我们支持的语言 */
|
|
1968
|
+
declare function isLang(value: string): value is Lang;
|
|
1969
|
+
/** 一份 catalog 的加载结果。`error` 非空时 `entries` 是空表,不是半份 */
|
|
1970
|
+
interface CatalogLoad {
|
|
1971
|
+
/** 点分 key → 文案。嵌套的 yaml 在这里已经被拍平 */
|
|
1972
|
+
entries: Record<string, string>;
|
|
1973
|
+
/** 读不到 / 解析不了的原因;一切正常时是 undefined */
|
|
1974
|
+
error?: string;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* i18n 自己的健康问题。宿主拿去塞进启动诊断。
|
|
1978
|
+
*
|
|
1979
|
+
* **不是 protocol 的 `Diagnostic`** —— infra 不许依赖 protocol(见 async.ts 的
|
|
1980
|
+
* `DiagnosticCollector`),所以只给 `code` + `detail` 这两样宿主真正要用的,
|
|
1981
|
+
* 由宿主决定挂在哪个 module 上、算 warn 还是 skipped。
|
|
1982
|
+
*/
|
|
1983
|
+
interface I18nDiagnostic {
|
|
1984
|
+
/** 机器可读的原因码,宿主分支用 */
|
|
1985
|
+
code: string;
|
|
1986
|
+
/** 一句人话。**过 `t()`**,所以它自己也跟着当前语言走 */
|
|
1987
|
+
detail: string;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* catalog 目录。两种形态都要认:
|
|
1991
|
+
*
|
|
1992
|
+
* - **发布形态** `<包>/dist/locales`:构建期由
|
|
1993
|
+
* [copy-locales.mjs](../scripts/copy-locales.mjs) 从仓库根拷进来。
|
|
1994
|
+
* `files: ["dist"]` 只发 dist,不拷就等于「装了包的人一句文案都没有」
|
|
1995
|
+
* - **开发形态** 仓库根 `locales/`:`pnpm epoch:dev` 和 vitest 吃的都是 `src/`,
|
|
1996
|
+
* 那时 dist 可能还是上一次的产物
|
|
1997
|
+
*
|
|
1998
|
+
* 往上找到 `node_modules` 就**停**:不停的话,装了 epoch 的项目要是自己根目录
|
|
1999
|
+
* 也有个 `locales/zh.yaml`(很常见),我们会把**用户项目的**文案当成 epoch 的
|
|
2000
|
+
* 界面文案加载进来。
|
|
2001
|
+
*/
|
|
2002
|
+
declare function localesDir(): string | undefined;
|
|
2003
|
+
/**
|
|
2004
|
+
* 读一份 catalog。**失败不抛** —— 返回空表 + 一条 error,调用方(`t()`)
|
|
2005
|
+
* 自然落进回落链的下一层。
|
|
2006
|
+
*
|
|
2007
|
+
* 结果带缓存:`t()` 在渲染热路径上,每次调用都去 stat 一次文件不合适。
|
|
2008
|
+
* 用例改了 `EPOCH_LOCALES_DIR` 之后调 {@link resetI18n} 清掉。
|
|
2009
|
+
*/
|
|
2010
|
+
declare function loadCatalog(lang: Lang): CatalogLoad;
|
|
2011
|
+
/** 清掉 catalog 缓存、目录探测缓存和已积累的诊断。**只给用例用** */
|
|
2012
|
+
declare function resetI18n(): void;
|
|
2013
|
+
/**
|
|
2014
|
+
* 一句文案里用到的占位符(去重,按出现顺序)。
|
|
2015
|
+
*
|
|
2016
|
+
* 导出它是因为**判据必须只有一份**:`t()` 按这个正则替换,一致性用例按同一个
|
|
2017
|
+
* 正则比对 zh / en 两边。抄成两份的话,「翻译时漏了 `{path}`」这种错误
|
|
2018
|
+
* 就会从「用例红」退化成「运行时少一句关键信息,而且不报错」。
|
|
2019
|
+
*/
|
|
2020
|
+
declare function placeholdersOf(text: string): string[];
|
|
2021
|
+
/**
|
|
2022
|
+
* 取一条文案。**永远返回字符串,永远不抛**。
|
|
2023
|
+
*
|
|
2024
|
+
* 回落链见文件头。`vars` 里没给的占位符**原样留着**(`{path}` 照样显示)——
|
|
2025
|
+
* 换成空串会让一句话看起来完好无损,实际上丢了关键信息,那正是最难查的形态。
|
|
2026
|
+
*
|
|
2027
|
+
* @param key 点分 key,如 `permission.denied_dangerous`
|
|
2028
|
+
* @param vars `{name}` 占位符的取值
|
|
2029
|
+
* @param lang 只翻这一条时的语言覆盖;不给走 {@link currentLang}。
|
|
2030
|
+
* **网线上那一段走的就是它**(方案 58):一条 HTTP 请求带
|
|
2031
|
+
* `?lang=en` 时,服务端拿这个参数渲染**这一次响应**,
|
|
2032
|
+
* 而进程语言({@link setLang})一个字都不动
|
|
2033
|
+
*/
|
|
2034
|
+
declare function t(key: string, vars?: Record<string, string | number>, lang?: Lang): string;
|
|
2035
|
+
/**
|
|
2036
|
+
* 当前语言。第一次访问时按 {@link resolveLang} 探测一次并记住 ——
|
|
2037
|
+
* 宿主忘了调 `setLang()` 时也该是「按系统 locale 走」,而不是硬吃 `zh`。
|
|
2038
|
+
*/
|
|
2039
|
+
declare function currentLang(): Lang;
|
|
2040
|
+
/**
|
|
2041
|
+
* 钉住进程级语言。装配层读完配置后调它落进来。
|
|
2042
|
+
*
|
|
2043
|
+
* 用模块级状态而不是给每个消费方传参:`t()` 会被几百处调用,逐个穿参等于把
|
|
2044
|
+
* i18n 变成一次全仓改造。infra 里 `setLogLevel` / `setShell` / `setSecretStore`
|
|
2045
|
+
* 都是这个形态。
|
|
2046
|
+
*
|
|
2047
|
+
* ## ⚠️ 「几百处」这句 2026-08-17 收窄了:**对全仓成立,对网线那一段不成立**
|
|
2048
|
+
*
|
|
2049
|
+
* [方案 58 §1.2](../../../.agents/plans/58-wire-lang-negotiation-plan.md) 数了一遍:
|
|
2050
|
+
* 全仓非浏览器的 `t('…')` 调用点是 **272 处**,其中**会把产物送上网线的只有 80 上下**
|
|
2051
|
+
* (`server` 41 + `runtime` 的诊断那一批 + `core` / `infra` 少数几处),
|
|
2052
|
+
* 而 **`cli` 那 175 处一处都不用动** —— 它们打在终端上,跟着进程 locale 走才是对的。
|
|
2053
|
+
*
|
|
2054
|
+
* 所以网线上那一段**就是逐个穿参**的:`t()` 的第三个参数(`lang`)存在的理由就是它。
|
|
2055
|
+
* 这里的模块级状态从此只答一个问题 —— **这个进程自己**说哪种话:终端输出、
|
|
2056
|
+
* 落盘 / 进历史的文本、以及**没带 `?lang=` 的请求**。
|
|
2057
|
+
*
|
|
2058
|
+
* ## ⚠️ **网线上的请求不许调它**
|
|
2059
|
+
*
|
|
2060
|
+
* 一个标签页的语言偏好不许改掉另一个标签页看见的东西 ——
|
|
2061
|
+
* 判据原文在[方案 43 §2.9](../../../docs/verify/VERIFY_RECORD-43-web-ui-shell.md),
|
|
2062
|
+
* 方案 58 §2.5 把它连同另外两条同源的(cookie 带语言、`AsyncLocalStorage` 做
|
|
2063
|
+
* 请求期环境变量)一起列成禁令。今天有一道物理闸门帮着守:
|
|
2064
|
+
* `@epoch-agent/runtime` 转 `t` 但**刻意不转 `setLang`**,于是 `@epoch-agent/server`
|
|
2065
|
+
* 压根 import 不到它(判据在 `runtime/src/index.ts`)。**别为了方便把它转出来。**
|
|
2066
|
+
*/
|
|
2067
|
+
declare function setLang(lang: Lang): void;
|
|
2068
|
+
/**
|
|
2069
|
+
* 系统 locale 的信号,**从高到低**。
|
|
2070
|
+
*
|
|
2071
|
+
* POSIX 那四个环境变量按 POSIX 自己的优先级排(`LC_ALL` 压一切)。
|
|
2072
|
+
* 最后兜底的是 `Intl` —— 它是 **Windows 上唯一能拿到 UI 语言的免子进程办法**
|
|
2073
|
+
* (等价于 `GetUserDefaultUILanguage`),macOS 上终端没设 `LANG` 时也靠它。
|
|
2074
|
+
* Node 22 自带完整 ICU,不需要额外依赖。
|
|
2075
|
+
*
|
|
2076
|
+
* 导出它是为了让用例能注入一组假信号 —— 「一个 locale 都探测不到」这种场景
|
|
2077
|
+
* 在真机上造不出来(`Intl` 总会返回点什么)。
|
|
2078
|
+
*/
|
|
2079
|
+
declare function systemLocaleSignals(): string[];
|
|
2080
|
+
/**
|
|
2081
|
+
* 解析该用哪个语言。顺序固定:
|
|
2082
|
+
*
|
|
2083
|
+
* ```
|
|
2084
|
+
* 1. EPOCH_LANGUAGE 环境变量(测试 / 临时覆盖)
|
|
2085
|
+
* 2. 传进来的 configured(config.yaml 的 display.language)
|
|
2086
|
+
* 3. 系统 locale:以 zh 开头 → zh;其它任何已知语言 → en
|
|
2087
|
+
* 4. 完全探测不到 → DEFAULT_LANG(zh)
|
|
2088
|
+
* ```
|
|
2089
|
+
*
|
|
2090
|
+
* ## 第 3 条那句「其它任何已知语言 → en」不是笔误
|
|
2091
|
+
*
|
|
2092
|
+
* 它看起来和「中文是默认语言」矛盾,其实不矛盾:**「默认」的语义是「没有任何
|
|
2093
|
+
* 信号时用它」**,也就是第 4 条。系统 locale **是一个信号** —— 一个法语系统的
|
|
2094
|
+
* 用户看英文界面比看中文界面强得多;中文对他不是「默认」,是「乱码级别的体验」。
|
|
2095
|
+
*
|
|
2096
|
+
* 只支持两种语言让这条判断很简单:**要么是中文用户,要么不是**。
|
|
2097
|
+
*
|
|
2098
|
+
* ## 坏值一律「当没给」,不抛
|
|
2099
|
+
*
|
|
2100
|
+
* `EPOCH_LANGUAGE=ja` 和 `display.language: ja` 都只是跳过这一层继续往下看。
|
|
2101
|
+
* 配置那一层的值域校验由 `EpochConfigSchema` 做(写错会产出一条带字段路径的
|
|
2102
|
+
* warn 诊断),这里再抛一次只会让 agent 因为一个显示偏好起不来。
|
|
2103
|
+
*
|
|
2104
|
+
* @param configured config.yaml 的 `display.language`,没配就不传
|
|
2105
|
+
* @param signals 系统 locale 信号,默认取 {@link systemLocaleSignals};
|
|
2106
|
+
* 用例传 `[]` 就能造出「一个 locale 都探测不到」
|
|
2107
|
+
*/
|
|
2108
|
+
declare function resolveLang(configured?: string, signals?: readonly string[]): Lang;
|
|
2109
|
+
/**
|
|
2110
|
+
* catalog 加载期攒下的问题,渲染成人话。
|
|
2111
|
+
*
|
|
2112
|
+
* **只反映已经发生过的加载** —— 没调过 `t()` 就还没读文件,这里自然是空的。
|
|
2113
|
+
* 宿主想把它当启动自检用的话,先调一次 `loadCatalog(currentLang())`。
|
|
2114
|
+
*/
|
|
2115
|
+
declare function i18nDiagnostics(): I18nDiagnostic[];
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* 凭据存储 —— infra 侧的本地契约。
|
|
2119
|
+
*
|
|
2120
|
+
* ## 为什么这里又写了一遍接口
|
|
2121
|
+
*
|
|
2122
|
+
* 真契约在 [protocol/src/secret.ts](../../../protocol/src/secret.ts) —— 宿主要注入
|
|
2123
|
+
* 自己的实现,只能依赖 protocol。但 **infra 不许依赖任何 `@epoch-agent/*` 包**
|
|
2124
|
+
* ([check-layers.mjs](../../../../scripts/check-layers.mjs) 的 `ALLOWED` 表里
|
|
2125
|
+
* infra 的允许项是空数组,反向即 CI 红),所以 infra 不能 `import type` 它。
|
|
2126
|
+
*
|
|
2127
|
+
* 靠 TypeScript 的**结构类型**对齐:两边形状一样,赋值就成立。对齐不是靠自觉,
|
|
2128
|
+
* 是靠 [runtime/src/build.ts](../../../runtime/src/build.ts) 里那句
|
|
2129
|
+
* `const store: SecretStore = await createSecretStore(...)` —— 它同时看得见两个包,
|
|
2130
|
+
* 形状一旦漂移,`pnpm typecheck` 立刻红。改这里的接口就得改那边,反之亦然。
|
|
2131
|
+
*/
|
|
2132
|
+
/** 内置后端标识。宿主注入的实现可以用自己的字符串 */
|
|
2133
|
+
type SecretBackendId = 'keychain' | 'dpapi' | 'libsecret' | 'plaintext';
|
|
2134
|
+
/** 必须与 protocol/src/secret.ts 的 `SecretStore` 保持结构一致 */
|
|
2135
|
+
interface SecretStore {
|
|
2136
|
+
readonly backend: SecretBackendId | (string & {});
|
|
2137
|
+
readonly encrypted: boolean;
|
|
2138
|
+
readonly detail: string;
|
|
2139
|
+
get(name: string): Promise<string | undefined>;
|
|
2140
|
+
set(name: string, value: string): Promise<void>;
|
|
2141
|
+
delete(name: string): Promise<void>;
|
|
2142
|
+
list(): Promise<string[]>;
|
|
2143
|
+
}
|
|
2144
|
+
/** 存储里的服务名 / 命名空间。与 protocol 的 `SECRET_SERVICE` 同值 */
|
|
2145
|
+
declare const SECRET_SERVICE = "epoch-agent";
|
|
2146
|
+
/** 凭据文件权限:仅属主可读写 */
|
|
2147
|
+
declare const SECRET_MODE = 384;
|
|
2148
|
+
/** 名字合法性:只允许可见 ASCII,且不含分隔符 —— 名字要进命令行参数和文件 key */
|
|
2149
|
+
declare function assertValidSecretName(name: string): void;
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* 钥匙串后端的名字索引 —— `~/.epoch/secrets-index.json`。
|
|
2153
|
+
*
|
|
2154
|
+
* ## 为什么需要它
|
|
2155
|
+
*
|
|
2156
|
+
* `SecretStore.list()` 在系统钥匙串上没有便宜的实现方式:
|
|
2157
|
+
*
|
|
2158
|
+
* - macOS:`security dump-keychain` 会把**整个钥匙串**倒出来,而且可能弹解锁窗口。
|
|
2159
|
+
* 为了列几个名字付这个代价不可接受。
|
|
2160
|
+
* - Linux:`secret-tool search --all` 倒是能列,但输出格式随 libsecret 版本变,
|
|
2161
|
+
* 而且同样要走一次 D-Bus 往返。
|
|
2162
|
+
*
|
|
2163
|
+
* 没有 `list()` 的直接后果是**启动预取要盲扫**:15 个 provider 的环境变量名逐个
|
|
2164
|
+
* `security find-generic-password`,就是 15 次子进程 —— macOS 上 200~300ms
|
|
2165
|
+
* 全花在启动路径上。有索引之后典型只读 1~2 个。
|
|
2166
|
+
*
|
|
2167
|
+
* ## 它是提示,不是真源
|
|
2168
|
+
*
|
|
2169
|
+
* 索引和钥匙串会不同步(用户在 Keychain Access 里手删了一条、换了机器同步过来
|
|
2170
|
+
* 半份配置)。所以:**`get()` 永远是权威**,索引只用来决定「去问哪些名字」。
|
|
2171
|
+
* 预取时读不出来的名字会被就地剔除(自愈),不会让一条陈旧索引一直挂着。
|
|
2172
|
+
*
|
|
2173
|
+
* 里面**只有名字没有值**(`OPENAI_API_KEY` 这种)。仍然写 0600:
|
|
2174
|
+
* 「这台机器配了哪几家 provider」也不是该随便给同机器其他用户看的。
|
|
2175
|
+
*/
|
|
2176
|
+
declare class SecretCatalog {
|
|
2177
|
+
private readonly filePath;
|
|
2178
|
+
private cache;
|
|
2179
|
+
constructor(filePath: string);
|
|
2180
|
+
/** 已登记的名字,顺序稳定(排序过),方便 `epoch config secret list` 输出稳定 */
|
|
2181
|
+
read(): string[];
|
|
2182
|
+
add(name: string): void;
|
|
2183
|
+
remove(name: string): void;
|
|
2184
|
+
/** 整体替换。预取自愈用:把「钥匙串里真读得出来」的那份写回去 */
|
|
2185
|
+
replace(names: string[]): void;
|
|
2186
|
+
/** 仅供测试:丢掉内存缓存,强制重新读盘 */
|
|
2187
|
+
invalidate(): void;
|
|
2188
|
+
/**
|
|
2189
|
+
* 原子写 + 建文件时就 0600。
|
|
2190
|
+
*
|
|
2191
|
+
* mode 在**创建时**生效,写完再 chmod 会留 TOCTOU 窗口
|
|
2192
|
+
* [对标 hermes mcp_oauth.py:388 的 `_write_secure_json`]。
|
|
2193
|
+
*/
|
|
2194
|
+
private write;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/**
|
|
2198
|
+
* 信封加密 —— 给「只能同步写盘」的调用方用的桥。
|
|
2199
|
+
*
|
|
2200
|
+
* ## 它为什么存在
|
|
2201
|
+
*
|
|
2202
|
+
* `SecretStore` 必然是异步的(要 shell out)。但 MCP 的 `OAuthClientProvider`
|
|
2203
|
+
* 整套接口是**同步 void**(`saveTokens(tokens): void`、`saveCodeVerifier(v): void`),
|
|
2204
|
+
* 那是 MCP SDK 定的形状,签名不归我们改。fire-and-forget 不行:
|
|
2205
|
+
* `epoch mcp login` 写完 token 立刻退进程,异步的 `security` 调用可能还没落地,
|
|
2206
|
+
* 用户的登录就丢了。
|
|
2207
|
+
*
|
|
2208
|
+
* 于是分两层:
|
|
2209
|
+
*
|
|
2210
|
+
* - **钥匙串里只放一把 32 字节随机数据密钥**(异步,启动时预取一次)
|
|
2211
|
+
* - **文件内容用它做 AES-256-GCM**(`node:crypto`,同步,微秒级)
|
|
2212
|
+
*
|
|
2213
|
+
* 落盘仍然是原子写 + 建文件时就 0600,和改造前一模一样,只是内容变成了密文。
|
|
2214
|
+
*
|
|
2215
|
+
* ## 这不是当初否掉的那个「自己实现加密」
|
|
2216
|
+
*
|
|
2217
|
+
* 被否掉的是「AES + 主密码」,理由是主密码要么存磁盘(等于没加密)要么
|
|
2218
|
+
* 每次问用户(CLI 场景不可接受)。这里的密钥两条都不占:
|
|
2219
|
+
* **随机生成、只躺在系统钥匙串里、从不落盘**。攻击者拿到 `mcp-auth.json`
|
|
2220
|
+
* 只有密文;要拿到密钥就得先过钥匙串这一关 —— 和 provider key 一个门槛。
|
|
2221
|
+
*
|
|
2222
|
+
* 对比 gemini-cli 的 `FileKeychain`:它的密钥是
|
|
2223
|
+
* `scrypt('gemini-cli-oauth', hostname+username+'gemini-cli')` 派生的,
|
|
2224
|
+
* 也就是**任何知道算法的人在同一台机器上都能重算出来**,那只能算混淆。
|
|
2225
|
+
* 这里刻意不走那条路。
|
|
2226
|
+
*
|
|
2227
|
+
* ## 降级后端下不生成数据密钥
|
|
2228
|
+
*
|
|
2229
|
+
* 密钥和密文躺在同一个目录里的「加密」是自欺。明文后端下
|
|
2230
|
+
* `getDataKey()` 返回 null,文件保持今天的明文形状,由启动诊断如实说明。
|
|
2231
|
+
*/
|
|
2232
|
+
/** 密文前缀。有它才能一眼分出「这份文件是密文」还是「老的明文 JSON」 */
|
|
2233
|
+
declare const ENVELOPE_PREFIX = "epoch-enc:v1:";
|
|
2234
|
+
/** 由装配层在预取到数据密钥后调用。传 null 表示「本次运行没有加密能力」 */
|
|
2235
|
+
declare function setDataKey(key: Buffer | null): void;
|
|
2236
|
+
declare function getDataKey(): Buffer | null;
|
|
2237
|
+
/** 生成一把新的数据密钥(base64,准备存进 SecretStore) */
|
|
2238
|
+
declare function generateDataKey(): string;
|
|
2239
|
+
/**
|
|
2240
|
+
* 把 SecretStore 里取回的 base64 密钥解析成 Buffer。
|
|
2241
|
+
*
|
|
2242
|
+
* 长度不对就返回 null 而不是抛:一把被改坏的密钥不该让 agent 起不来,
|
|
2243
|
+
* 该走的是「降级 + 响亮说明」那条路。
|
|
2244
|
+
*/
|
|
2245
|
+
declare function parseDataKey(encoded: string | undefined): Buffer | null;
|
|
2246
|
+
/** 明文 → `epoch-enc:v1:<base64(iv|tag|ciphertext)>` */
|
|
2247
|
+
declare function sealEnvelope(key: Buffer, plaintext: string): string;
|
|
2248
|
+
/** 是不是本模块写出来的密文(用来区分「要解密」和「是老的明文文件」) */
|
|
2249
|
+
declare function isEnvelope(text: string): boolean;
|
|
2250
|
+
/**
|
|
2251
|
+
* 密文 → 明文。**认证失败一律抛** —— GCM 的 authTag 对不上意味着文件被改过
|
|
2252
|
+
* 或者密钥不是这一把,这两种情况都不该「尽力而为地」返回半截数据。
|
|
2253
|
+
*/
|
|
2254
|
+
declare function openEnvelope(key: Buffer, blob: string): string;
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* `.env` → SecretStore 的一次性迁移。
|
|
2258
|
+
*
|
|
2259
|
+
* 立项时点名这是「最容易做砸的部分」,五条硬性要求逐条落在下面:
|
|
2260
|
+
*
|
|
2261
|
+
* 1. **只做一次**:钥匙串里已经有的名字不再搬(幂等,再启动一次不重复迁移、
|
|
2262
|
+
* 不再产生备份文件)。
|
|
2263
|
+
* 2. **永远不静默删 `.env`**:搬完把整个文件改名成 `.env.migrated-<日期>`。
|
|
2264
|
+
* 用户可能有别的工具在读它,直接删是不可逆操作。
|
|
2265
|
+
* 3. **结果进启动诊断**:搬了几个、备份成什么名字,一行说清。
|
|
2266
|
+
* 4. **降级后端下不迁移**,也不假装迁移 —— 调用方按 `encrypted` 判,见 index.ts。
|
|
2267
|
+
* 5. 逃生口在 `epoch config secret --export`(cli 那边),不在这里。
|
|
2268
|
+
*
|
|
2269
|
+
* ## 一个刻意的选择:搬完是「整体改名」而不是「注释掉已迁移的行」
|
|
2270
|
+
*
|
|
2271
|
+
* 注释掉的做法会留下一个既不是完整备份、也不是干净配置的中间态文件,
|
|
2272
|
+
* 而且用户自己写在里面的非凭据变量会被夹在一堆注释里。整体改名之后
|
|
2273
|
+
* 「原来的东西一个字没少,只是不在生效路径上了」这件事一眼就能确认。
|
|
2274
|
+
*
|
|
2275
|
+
* ## 失败时宁可不搬
|
|
2276
|
+
*
|
|
2277
|
+
* 任何一个 `set()` 抛了(钥匙串突然锁了、磁盘满了),整次迁移**立刻停手且不
|
|
2278
|
+
* 改名 `.env`**:搬了一半就把源文件改名,等于把用户的 key 弄丢一半。
|
|
2279
|
+
* 已经搬进去的那几个不回滚 —— 它们和 `.env` 里的原值一样,重复存在无害,
|
|
2280
|
+
* 下次启动会接着搬剩下的。
|
|
2281
|
+
*/
|
|
2282
|
+
|
|
2283
|
+
interface MigrateOptions {
|
|
2284
|
+
/** `~/.epoch/.env` 的路径 */
|
|
2285
|
+
envFilePath: string;
|
|
2286
|
+
/** 目标存储。**调用方必须先确认 `encrypted === true`** */
|
|
2287
|
+
store: SecretStore;
|
|
2288
|
+
/**
|
|
2289
|
+
* 允许迁移的变量名清单(provider 的 API key 环境变量)。
|
|
2290
|
+
*
|
|
2291
|
+
* 由调用方传进来,不在这里写死:清单的真源是 `protocol` 的
|
|
2292
|
+
* `API_KEY_ENV_VARS`,而 infra 不许依赖 protocol。
|
|
2293
|
+
*/
|
|
2294
|
+
names: readonly string[];
|
|
2295
|
+
/** 备份文件名里的日期戳,形如 `20260806`。传进来是为了用例可复现 */
|
|
2296
|
+
stamp: string;
|
|
2297
|
+
}
|
|
2298
|
+
interface MigrateResult {
|
|
2299
|
+
/** 真正搬进钥匙串的名字 */
|
|
2300
|
+
migrated: string[];
|
|
2301
|
+
/** `.env` 被改成了什么(没迁移就是 undefined) */
|
|
2302
|
+
backupPath?: string;
|
|
2303
|
+
/** 出问题时的一句说明,会进启动诊断 */
|
|
2304
|
+
warning?: string;
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* 跑一次迁移。**不抛异常** —— 迁移失败不该让 agent 起不来,
|
|
2308
|
+
* 该让用户在诊断里看见「没搬成,原因是什么」。
|
|
2309
|
+
*/
|
|
2310
|
+
declare function migrateEnvSecrets(opts: MigrateOptions): Promise<MigrateResult>;
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* 降级后端 —— 还是今天那个 `~/.epoch/.env`,明文 + 0600。
|
|
2314
|
+
*
|
|
2315
|
+
* ## 为什么降级后端落在 `.env` 而不是一个新文件
|
|
2316
|
+
*
|
|
2317
|
+
* 因为**降级路径上的正确行为是「什么都不变」**。钥匙串探测不过的机器(headless
|
|
2318
|
+
* Linux、CI、没装 libsecret 的发行版)如果因为升级而让 key 换了个文件名,
|
|
2319
|
+
* 用户 `cat ~/.epoch/.env` 发现空的、别的工具读这个文件也读不到了 ——
|
|
2320
|
+
* 一个安全特性把没能受益的人的东西弄坏了,这是最差的结果。
|
|
2321
|
+
*
|
|
2322
|
+
* 所以这个后端就是把既有的 `.env` 读写逻辑收进 `SecretStore` 的形状里:
|
|
2323
|
+
* 明文后端下整条链路的可观察行为和改造前**逐字节一致**,
|
|
2324
|
+
* 唯一的新增是启动诊断里那行响亮的降级说明。
|
|
2325
|
+
*
|
|
2326
|
+
* ## `encrypted = false` 是这里最重要的一个字段
|
|
2327
|
+
*
|
|
2328
|
+
* 迁移看它决定「不迁移,也不假装迁移」;信封加密看它决定不生成数据密钥
|
|
2329
|
+
* (密钥和密文躺在同一个目录里的加密是自欺);诊断和 `epoch doctor` 看它
|
|
2330
|
+
* 决定标 WARN 而不是 OK。
|
|
2331
|
+
*
|
|
2332
|
+
* `.env` 的就地改写逻辑(去重、保留注释)也放在这里,
|
|
2333
|
+
* [cli/src/files/env-file.ts](../../../cli/src/files/env-file.ts) 转出同一份 ——
|
|
2334
|
+
* `epoch model` 写 key、`epoch config secret --export` 倒出 key、明文后端落盘,
|
|
2335
|
+
* 三条路径写的是同一个文件,策略不能有三份。
|
|
2336
|
+
*/
|
|
2337
|
+
|
|
2338
|
+
declare class PlaintextStore implements SecretStore {
|
|
2339
|
+
private readonly envFilePath;
|
|
2340
|
+
readonly backend = "plaintext";
|
|
2341
|
+
readonly encrypted = false;
|
|
2342
|
+
readonly detail: string;
|
|
2343
|
+
/**
|
|
2344
|
+
* @param reason 为什么降级。**必须带修复方法** —— 只说「明文」而不说怎么修,
|
|
2345
|
+
* 用户除了忍着没有别的选择
|
|
2346
|
+
*/
|
|
2347
|
+
constructor(envFilePath: string, reason: string);
|
|
2348
|
+
get(name: string): Promise<string | undefined>;
|
|
2349
|
+
set(name: string, value: string): Promise<void>;
|
|
2350
|
+
delete(name: string): Promise<void>;
|
|
2351
|
+
list(): Promise<string[]>;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* 就地设置一个变量:已存在就改那一行,不存在才追加。
|
|
2355
|
+
*
|
|
2356
|
+
* 不去重的后果不是「不好看」而是「不知道哪行生效」:解析是后行覆盖前行,
|
|
2357
|
+
* 重配三次 groq 就留三行不同的 key,用户删错一行就坏。
|
|
2358
|
+
*/
|
|
2359
|
+
declare function setEnvVarText(text: string, name: string, value: string): string;
|
|
2360
|
+
/** 删掉一个变量的全部赋值行,注释和其他行原样保留 */
|
|
2361
|
+
declare function removeEnvVarText(text: string, name: string): string;
|
|
2362
|
+
/** 解析成 name → value。引号去掉,注释和空行跳过 */
|
|
2363
|
+
declare function parseEnvText(text: string): Record<string, string>;
|
|
2364
|
+
/**
|
|
2365
|
+
* 写入并把权限收到 0600。
|
|
2366
|
+
*
|
|
2367
|
+
* `writeFileSync` 的 `mode` 只在**创建**文件时生效,所以文件已存在时还得再
|
|
2368
|
+
* chmod 一次。chmod 在 Windows / 某些挂载的文件系统上是空操作 —— 这正是
|
|
2369
|
+
* 本方案存在的理由之一,不是可以忽略的小事,但也不该因此让写入失败。
|
|
2370
|
+
*/
|
|
2371
|
+
declare function writeEnvFile(path: string, text: string): void;
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* 凭据存储 —— 后端选择、探测、预取,以及进程级的两个持有者。
|
|
2375
|
+
*
|
|
2376
|
+
* ## 探测顺序与降级
|
|
2377
|
+
*
|
|
2378
|
+
* 按平台各试一个后端,**探不过就降级到明文**。降级本身是可以接受的
|
|
2379
|
+
* (功能不能因为没有钥匙串就不可用),但必须**响亮**:
|
|
2380
|
+
* `SecretStore.detail` 里一定带着「为什么降级」和「怎么修」,
|
|
2381
|
+
* 装配层把它原样打进启动诊断,`epoch doctor` 据 `encrypted` 标 WARN。
|
|
2382
|
+
*
|
|
2383
|
+
* 方案 02 的教训(整套东西合进 main、一行都没接上、1105 条用例全绿也没拦住)
|
|
2384
|
+
* 在这里的复现形式是「钥匙串探测失败 → 静默退回明文 → 用户以为自己加密了」。
|
|
2385
|
+
* 所以这个模块里**没有任何一条**降级路径是不带原因字符串的。
|
|
2386
|
+
*
|
|
2387
|
+
* ## 同步 / 异步的坎 —— 为什么是「启动时预取」
|
|
2388
|
+
*
|
|
2389
|
+
* `loadConfig()` 是同步的(`runtime/src/build.ts` 同步调它),而 SecretStore
|
|
2390
|
+
* 必然异步(要 shell out)。当初摆在桌上的是三条路,选的是第二条:
|
|
2391
|
+
*
|
|
2392
|
+
* - **把 `loadConfig` 改 async** —— 传染性最强,所有调用方全要改,
|
|
2393
|
+
* 而那片改动区和可嵌入宿主那一轮完全重叠
|
|
2394
|
+
* - ✅ **启动时预取一次**:`prefetchProviderSecrets()` 把凭据读成一张 map,
|
|
2395
|
+
* 装配层交给 loader 当「虚拟 env」用(第 3.5 层,压在 `.env` 之下、
|
|
2396
|
+
* `process.env` 仍然最高)。改动集中在装配层,且天然解决 loader 的缓存问题
|
|
2397
|
+
* - **`execFileSync` 同步 shell out** —— 最省事,但会在启动路径上引入一次
|
|
2398
|
+
* 同步子进程(Windows 上拉 PowerShell 可能 200ms+),不接受
|
|
2399
|
+
*
|
|
2400
|
+
* 预取的代价是凭据在进程内存里是明文 —— 这是所有做法的共同点(最终总要把 key
|
|
2401
|
+
* 交给 HTTP 客户端),不是回退。但要求:**不许把这张 map 整个写进日志或诊断**,
|
|
2402
|
+
* 需要展示时走 [mask.ts](../mask.js) 的 `maskSensitive()`。
|
|
2403
|
+
*/
|
|
2404
|
+
|
|
2405
|
+
interface CreateSecretStoreOptions {
|
|
2406
|
+
/** 家目录。不传走 `resolveHomeDir()`(认 EPOCH_HOME / EPOCH_PROFILE) */
|
|
2407
|
+
homeDir?: string;
|
|
2408
|
+
/**
|
|
2409
|
+
* 宿主注入的实现(Electron 的 `safeStorage` 之类)。
|
|
2410
|
+
*
|
|
2411
|
+
* 给了就**完全跳过三个平台后端的探测** —— 宿主比我们更清楚自己那套能不能用,
|
|
2412
|
+
* 而探测本身要起子进程,没必要为一个用不上的结论付这个钱。
|
|
2413
|
+
*/
|
|
2414
|
+
inject?: SecretStore;
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* 选一个能用的后端。
|
|
2418
|
+
*
|
|
2419
|
+
* 永远返回一个可用的 store(最差是明文),不抛异常 —— 凭据存储起不来
|
|
2420
|
+
* 不该让 agent 起不来。
|
|
2421
|
+
*/
|
|
2422
|
+
declare function createSecretStore(opts?: CreateSecretStoreOptions): Promise<SecretStore>;
|
|
2423
|
+
/**
|
|
2424
|
+
* 启动预取 —— 方案 3.5 的 (b) 方案。
|
|
2425
|
+
*
|
|
2426
|
+
* 只读 `names` 和 store 里**都**有的那些:`list()` 是索引(提示),
|
|
2427
|
+
* `names` 是 provider 清单(真源)。两边取交集,典型情况下只有 1~2 次子进程,
|
|
2428
|
+
* 而盲扫 15 个 provider 的变量名在 macOS 上要 200~300ms —— 全花在启动路径上。
|
|
2429
|
+
*
|
|
2430
|
+
* 读不出来的名字会被就地剔出索引(自愈):用户在 Keychain Access 里手删过一条、
|
|
2431
|
+
* 或者换机器同步过来半份配置,都会留下陈旧索引。
|
|
2432
|
+
*
|
|
2433
|
+
* **失败不抛**:读不到凭据的后果是「没配 key」,agent 会正常报「无可用凭证」,
|
|
2434
|
+
* 比起不来好。
|
|
2435
|
+
*/
|
|
2436
|
+
declare function prefetchProviderSecrets(store: SecretStore, names: readonly string[]): Promise<{
|
|
2437
|
+
values: Record<string, string>;
|
|
2438
|
+
warning?: string;
|
|
2439
|
+
}>;
|
|
2440
|
+
/**
|
|
2441
|
+
* **同步**列出「存储里存过哪些名字」—— 只读文件,不起子进程。
|
|
2442
|
+
*
|
|
2443
|
+
* 存在的理由是一个真实踩到的回归:迁移完成之后 `epoch status` 打出
|
|
2444
|
+
* 「凭证: 未配置」。因为 `hasProviderCredentials()` 是同步的,它只看得见
|
|
2445
|
+
* `process.env` 和 `.env`,而 key 已经搬进钥匙串、`.env` 也改名走了 ——
|
|
2446
|
+
* 三把 key 好好地躺在那儿,CLI 却告诉用户「你还没配」,
|
|
2447
|
+
* 顺带还会在欢迎语里引导去重配一遍。
|
|
2448
|
+
*
|
|
2449
|
+
* 用文件而不是问后端,是因为这几个调用方(status / doctor / 欢迎语)都是同步的,
|
|
2450
|
+
* 而且它们只需要一个**布尔答案**,不需要值:
|
|
2451
|
+
*
|
|
2452
|
+
* - keychain / libsecret → 读名字索引 `secrets-index.json`
|
|
2453
|
+
* - dpapi → 读 `secrets.dpapi.json` 的 key
|
|
2454
|
+
* - plaintext → 落点就是 `.env`,`readProviderKeyEnv()` 本来就覆盖了
|
|
2455
|
+
*
|
|
2456
|
+
* 和索引一样,这是**提示**不是真源:名字在、值读不出来是可能的
|
|
2457
|
+
* (用户手动删过钥匙串条目)。对「要不要提示用户去配 key」这个用途足够了。
|
|
2458
|
+
*/
|
|
2459
|
+
declare function listStoredSecretNames(homeDir?: string): string[];
|
|
2460
|
+
/** 装配层选好后端之后调它。之后任何地方都能 `getSecretStore()` 拿到同一个 */
|
|
2461
|
+
declare function setSecretStore(store: SecretStore | null): void;
|
|
2462
|
+
declare function getSecretStore(): SecretStore | null;
|
|
2463
|
+
declare function setSecretValues(next: Record<string, string>): void;
|
|
2464
|
+
declare function getSecretValues(): Record<string, string>;
|
|
2465
|
+
/** 仅供测试:把进程级状态复位 */
|
|
2466
|
+
declare function resetSecretState(): void;
|
|
2467
|
+
|
|
2468
|
+
export { type ArtifactHandle, type BubblewrapOptions, CODE_EXEC_ROOTS, type CatalogLoad, type CommandSegment, type Confinement, type CreateSecretStoreOptions, DEFAULT_LANG, DEFAULT_YAML, DENIAL_SIGNATURES, type DangerCheckOptions, type DangerMatch, type DangerPlatform, type DiagnosticCollector, ENVELOPE_PREFIX, EXEC_PATH_AS_NODE_ENV, type FailureKind, type FailureVerdict, type I18nDiagnostic, IS_WINDOWS, type IsolatedCommand, type IsolationBackend, type IsolationOptions, type KillProcessTreeOptions, type KillSignalOptions, type KillablePty, LANGS, type Lang, type LogEntry, type LogLevel, type MigrateOptions, type MigrateResult, type Migration, type OnExisting, PROJECT_DIR_NAME, type ParseIssue, type ParseOutcome, type ParsedCommand, PlaintextStore, RIPGREP_INSTALL_HINT, RUNNER_FAILURE_RULES, type ResolveRipgrepOptions, type RipgrepMode, type RipgrepResolution, type RunnerFailureRules, SECRET_MODE, SECRET_SERVICE, SHELL_KINDS, SIGKILL_TIMEOUT_MS, type SandboxEnforcement, type SandboxMode, type SandboxPolicy, type SeatbeltOptions, type SecretBackendId, SecretCatalog, type SecretStore, type ShellFlavor, type ShellInvocation, type ShellKind, type SqliteDatabase, type StartLongLivedOptions, type StreamDecoder, TOOLCHAIN_CACHE_DIRS, type TrackedProcess, WINDOWS_HIDE_FLAGS, agentsDir, approvalsPath, artifactsDir, assertValidSecretName, automationDir, automationLogsDir, automationWorkDir, budgetStatePath, buildBwrapArgs, buildProfile, checkDangerousCommand, checkObfuscation, checkpointsDir, classifyFailure, closeAllDatabases, collectProcessTree, commandsDir, configPath, confine, countCodePoints, createLogger, createSecretStore, createStreamDecoder, currentLang, dbPath, detectBackend, detectConsoleEncoding, encodingForCodePage, envPath, formatIssues, generateDataKey, getDataKey, getDefaultShell, getPythonCommand, getSecretStore, getSecretValues, hasSideEffectChannel, headCodePoints, hooksPath, i18nDiagnostics, isEnvelope, isInWorkspace, isLang, isReadOnlyCommand, isSensitiveKey, isolate, issueDetails, keepHeadAndTail, keybindingsPath, killAllTrackedProcesses, killPids, killProcessTree, killTrackedProcess, listStoredSecretNames, loadCatalog, localesDir, managedSettingsPath, marketplacesPath, maskApiKey, maskSensitive, mcpAuthPath, mcpConfigPath, mcpSchemaCachePath, memoriesDir, migrate, migrateEnvSecrets, normalizeForMatch, openArtifact, openDatabase, openEnvelope, parseDataKey, parseEnvText, parseLenient, parseShellCommand, parseStrict, placeholdersOf, pluginsDir, pluginsStatePath, policiesDir, powershellCommandArg, prefetchProviderSecrets, probeBubblewrap, probeSeatbelt, probeShell, projectCommandsDir, projectHooksPath, projectLocalSettingsPath, projectPoliciesDir, projectSchemasDir, projectSettingsPath, projectSkillsDir, readConfigYaml, readKey, refCount, releaseDatabase, removeEnvVarText, resetI18n, resetRipgrepCache, resetSecretState, resolveHomeDir, resolveLang, resolveProfile, resolveRipgrep, resolveShellKind, safeInit, sandboxModeForLevel, schemaVersion, sealEnvelope, setDataKey, setEnvVarText, setLang, setLogLevel, setScalar, setSecretStore, setSecretValues, setSectionField, setShell, shellPtyArgs, shellSpawnArgs, skillsDir, splitWords, startLongLivedProcess, stripShellWrapper, systemLocaleSignals, t, tailCodePoints, trackedProcessCount, trustPath, trustedImportsPath, unknownKeyIssues, unsetScalar, unsetSectionField, withTimeout, workspacesPath, worktreesDir, writeArtifact, writeConfigYaml, writeEnvFile };
|