@epoch-agent/infra
v0.20.1
Published
epoch-agent 基础设施:路径真源、SQLite 连接与迁移、日志、平台兼容、命令安全表
Readme
@epoch-agent/infra
基础设施层。跨模块共用的叶子能力。
- ✅ 做:路径真源、SQLite 连接与迁移、日志、凭据存储、平台兼容、命令安全启发式表、
工作区归属判定(
workspace.ts)、外部可执行文件的探测与降级(ripgrep.ts)、异步小工具 - ❌ 不做:任何领域逻辑(ReAct / 记忆 / 权限判定都属于 core)、任何 UI
- 依赖:只有 node 内置模块 +
better-sqlite3+zod。不依赖任何@epoch-agent/*包
判断一个东西该不该放这里:无状态或只管进程级资源,且被两层以上共用。
workspace.ts 和 command-safety.ts 看着像「权限判定」,其实是同一类东西:
一张纯判据(这个路径在不在地盘里 / 这条命令像不像手滑),谁都能问、问了不改变
任何状态。「问到之后要不要放行、要不要弹审批」才是权限判定,那部分在 core。
判据落在 infra 是因为按分层 plugin-* 只能依赖 protocol + infra,够不着 core ——
放 core 就意味着两个 plugin 各抄一份,而这次 --add-dir 就是被那三份抄本坑到才收归一处的。
文件
| 文件 | 内容 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| paths.ts | ~/.epoch/ 下每个文件的唯一真源,外加机器级的 managedSettingsPath()。别处再 join(homedir(), '.epoch') 就是埋雷 |
| artifact-store.ts | 往 artifact 目录写文件的唯一写法:0700 目录 / 0600 文件 / open(path,'wx') 独占写。core 的多模态闸门和 plugin-terminal 的大输出落盘共用 |
| text-slice.ts | 按 Unicode 码点取头尾,截断不许切出半个字符(emoji 和 CJK 扩展 B 是代理对)。命令输出预览和工具结果裁剪共用 |
| workspace.ts | isInWorkspace(target, workDir, extraRoots)——「这个路径算不算在地盘里」的唯一判定,权限层 / plugin-file / plugin-terminal 共用 |
| sqlite.ts | 连接池(引用计数)+ 迁移。openDatabase / releaseDatabase 配对使用 |
| logger.ts | 结构化日志 |
| mask.ts | 脱敏(maskApiKey / isSensitiveKey),epoch config show 靠它 |
| platform.ts | Windows / POSIX 差异:shell 选择、spawn 与 pty 参数、控制台编码探测与解码 |
| ripgrep.ts | rg 从哪来的分档解析(几档、每档说什么,真源是文件头那张表):系统 PATH → 随包带的 vendor 二进制 → 都没有。嵌入宿主里 vendor 那一档会绕开 app.asar、改取 app.asar.unpacked 里那份(unpackAsarPath)。file_search 和将来的 @ 补全共用 |
| git-probe.ts | canProbeGit()——后台探测 git 之前先问的那一句。mac 上 /usr/bin/git 是命令行开发者工具的存根(和 clang / python3 / swift 同一个 inode),没装过工具的机器上一执行就弹系统安装框,而那个框 stdio: 'ignore' 挡不住、catch 也接不住。三处调用点共用:getGitStatus()、@ 补全的 listWorkspaceFiles()、工作区 diff 的 runGit()。拦下来一律退化不报错 |
| process-tree.ts | 进程树清理——杀干净孙进程,且只杀该杀的 |
| spawn-guard.ts | guardSpawn(start, onSpawnError)——起子进程那一下的同步失败通道。node 只把 EACCES / EAGAIN / EMFILE / ENFILE / ENOENT 转成异步 error 事件,名单外的(最常见 ENOTDIR:cwd 不是目录、PATH 里某一项是文件)同步抛,execFile 连回调都不调。全仓每一处 spawn / execFile 都要经过它,由根 __tests__/spawn-guard-callsites.test.ts 扫 AST 守着(六处登记在案的例外自己用 try/catch 接住了) |
| child-process.ts | 长期子进程(后台任务 / language server / PTY)的启动、登记、回收。start() 起不来时返回 null(2026-09-20,同步抛那一族)。表按所有者分:每个 runtime 一张(createProcessTable(),按 sessionId 路由)+ 一张进程级兜底表(引用计数,归零才收)—— 判据和那条真 bug 写在文件头。detached: false 的理由也在那儿。还有 EXEC_PATH_AS_NODE_ENV——「拿 process.execPath 当 node 使」的地方必须带的那一格(嵌入 Electron 的宿主里它不是 node),两个消费者分在 core 和 plugin-lsp |
| shell-parse.ts | 命令分词与分段(引号感知,带 POSIX / Windows 两种口味)。危险判定、权限规则匹配、斜杠命令的位置参数都建在它上面 |
| command-safety.ts | 危险命令与混淆的启发式表,建在 shell-parse 之上。是护栏不是沙箱 |
| sandbox/ | 这个才是沙箱:Seatbelt(macOS)/ bubblewrap(Linux,运行时暂未验证)/ 探测不到就降级。confine() 按调用带 policy 并如实报强制完整度;两组 stderr 分类器分开「被挡住」和「沙箱没起来」。2026-08-16 从 core 搬来,理由同 command-safety.ts:code_exec 和 plugin-terminal 共用,而 plugin 够不着 core |
| schema.ts | 配置校验(zod + 宽松解析,坏字段降级而不是整份拒绝) |
| i18n.ts | t() / 语言解析 / catalog 加载与三层回落。文案真源是仓库根的 locales/*.yaml |
| async.ts | 异步小工具 |
| secret/ | 凭据存储:DPAPI(Windows)/ Keychain(macOS)/ libsecret / 明文回落 + 迁移 |
界面文案:t() 与三层回落
i18n.ts 也是一个进程级状态(setLang('zh' | 'en'),形状同 setShell)。
Lang 的值域在这里和 protocol 各写了一份 —— 同 ShellKind,
本包零内部依赖,import 不了 protocol;根 __tests__/i18n-catalog.test.ts
里有一条用例锁着两边逐项相等。
文案的真源是仓库根的 locales/{zh,en}.yaml,不在本包里。发布时由
scripts/copy-locales.mjs 在 tsup 之后拷进 dist/locales
(files: ["dist"] 只发 dist,不拷就等于装了包的人满屏 key 路径)。
t() 永远返回字符串、永远不抛,三层回落:
目标语言的 key → zh(DEFAULT_LANG)的 key → 返回 key 路径本身第三层是关键:catalog 写坏了不能让 agent 起不来。同一条原则下,坏 YAML / 缺文件
只产出 i18nDiagnostics() 里的一条 warn,宿主拿去塞进启动诊断。
语言怎么选出来(EPOCH_LANGUAGE → display.language → 系统 locale → 中文)
见 docs/CONFIGURATION.md。
Windows 上的 shell 选择
platform.ts 里有一个进程级状态:setShell('cmd' | 'powershell' | 'pwsh'),
装配层(runtime)读完配置调一次,shellSpawnArgs / shellPtyArgs
据此拼参数。形状跟 setLogLevel / setSecretStore 一致。POSIX 上是空操作,
永远 /bin/sh —— macOS 走的就是这一支。
只收这三个具名值,不收任意可执行文件路径:每加一种都要重新验「引号怎么转义」 和「退出码怎么传」两件事,放开成任意路径等于承诺一件没验过的事。
PowerShell 那条路上有两个不报错的坑,都写进了 platform.ts 的注释:
- PowerShell 5.1 不传播被调命令的退出码(实测
-Command "cmd /c exit 3"→1), 所以命令后面必须接一段同时读$?和$LASTEXITCODE的收尾语句。只读后者更糟: 命令不存在时它是$null,exit $null→ 0,一次彻底失败被报成成功 - 命令和收尾之间必须是真换行,不能用
;。用;的话命令末尾带一句# 注释就把整段收尾注释掉了,实测cmd /c exit 5 # note退出码变成1
ShellKind 的值域在这里和 protocol 各写了一份——本包零内部依赖,
import 不了 protocol。core 里有一条用例锁着两边相等。
凭据存储的后端顺序
secret/catalog.ts 按平台探测可用后端,探不到就降级,最后回落到 ~/.epoch/.env 明文。
epoch config secret 打的就是这个判定结果。
⚠️ POSIX 分支不等于 Linux 分支,别顺手删。 macOS 走的就是 POSIX 那一支 (
pgrep/kill进程树、/bin/sh、危险命令表的 POSIX 那张)——删这些等于砍掉 macOS。真正只有 Linux 会走到的是显式判platform() === 'linux'的分支 (secret/libsecret.ts):Linux 暂不支持,所以那条路径尚未实测,但代码留着, 将来补 Linux 支持时从它接着往下走。
开发
pnpm --filter @epoch-agent/infra test