@lensagent/executor
v0.1.3
Published
Lens Harness Executor: the validation boundary for file and terminal Capabilities.
Readme
lens-remote-executor
lens-remote-executor 是 Lens Harness 的 Executor 服务:一个独立的 gRPC 进程,承担文件和终端 Capability 的唯一校验边界。它是 lens-core(Kernel/Server)的运行时对端,两者没有共享代码或依赖,只通过 gRPC 端口通信;本仓库不依赖 lens-core 的任何源码,可以独立安装、构建、部署到任意机器。
状态:v1 实现基线。 当前执行行为以项目源码、
proto/executor.proto、配置和可复核运行结果为准。lens-core仓库的docs/architecture/记录端到端设计意图,不是本仓库的执行基线。
与 lens-core 的关系
lens-core 作为 gRPC 客户端连接本服务监听的端口。两个仓库各自独立版本化、独立部署:
proto/executor.proto是本仓库分发的 gRPC 契约副本;lens-core侧维护另一份独立副本(core/proto/executor.proto)。两份副本目前靠人工保持一致——修改本仓库的 RPC 契约(新增/修改 message 或 rpc)时,必须同步修改lens-core侧的副本,否则两边的 Protobuf 定义会漂移而没有自动检测。之前当两者同处一个仓库时有 CI 的proto-driftjob 做字节级比对;拆分后暂时没有跨仓库的等价机制。src/execution/process.ts(本仓库的终端子进程运行器)与lens-core的core/src/execution/lsp-process.ts(LSP 子进程管理)是两份独立维护、不共享文件的实现,历史上源自同一段代码但已经按各自需求分别演化。
开发与运行
要求 Node.js 24 或更高版本,以及 PATH 上可用的 git(≥ 2.29 可启用 SHA-256 对象格式,更低版本自动回退到默认格式)。git 是运行前置条件:EditFile/PatchFile 的文件历史存在一个由本服务独占的 Git 对象库中,无法记录历史的写入会直接失败,而不是静默产生一个不可 revert 的改动。v1 声明支持 Windows、Linux 和 macOS;当前验收目标为 Windows 11 x64、Linux x64、macOS darwin-x64 和 darwin-arm64。
node scripts/secret-scan.mjs # 零依赖,扫描整仓
pnpm install
pnpm run typecheck
pnpm run lint
pnpm run build
EXECUTOR_ALLOWED_PARENT_DIR="$HOME" EXECUTOR_AUTO_VERIFY=true pnpm run devEXECUTOR_ALLOWED_PARENT_DIR 限定可浏览/创建 Workspace 的根目录,未设置时默认为进程自身的 cwd()。使用 $HOME 而不是仓库目录,是因为 WebUI 的 "New Project" 文件夹浏览器直接把该目录作为默认起始位置——设为仓库目录会让浏览器只能看到这一个 checkout,设为 $HOME 才能匹配原生 Finder/Explorer"默认从主目录开始"的习惯。EXECUTOR_AUTO_VERIFY=true 仅用于受控本地开发,跳过 config/pair.json 的人工审批;生产部署应省略该变量并手动审批调用方。
EXECUTOR_STATE_DIR 指定文件历史的存放位置,未设置时默认为进程 cwd() 下的 executor-state/。每个 Workspace 一个裸 Git 仓库(workspaces/<workspace-id>/history.git)加一个单调 sequence 文件,目录根下另有 executor.lock(单写者 pid)和 state.json(该存储的稳定身份);该目录位于 Workspace 之外,本服务从不接触用户自己的 .git。历史仓库的 lens.storageVersion=3 与记录的 version: 3 标记当前原生 Git 格式。此前的 zstd 历史(version 2)及无标记旧仓库会被明确拒绝,不能直接复用。
这个目录不是缓存。 它保存文件历史的唯一副本:删除它等于放弃所有可 revert 的历史(Workspace 里的文件本身不受影响)。因此它对工作树的正确性不是必需的,但如果 revert 能力有价值就应当纳入备份。存储被替换后可以从 Health 的 state_id 变化识别,此时 revert 会报 no_history 而不是含糊的 missing_record。
lens-core 侧 config/lens.jsonc 的 executor.address 必须与本服务实际监听地址一致(默认 127.0.0.1:50051,对应 EXECUTOR_PORT)。EXECUTOR_PROTO_PATH 可覆盖 proto/executor.proto 的解析路径,未设置时默认使用随本仓库分发的 proto/executor.proto(相对 src/main.ts/dist/main.js 自身位置解析,不依赖进程 cwd())。
部署到目标机器
本仓库本身即是完整、自包含的交付物:把整个仓库(不含 node_modules/、dist/)复制到目标机器(Node.js 24+),然后:
pnpm install --prod
pnpm run build
EXECUTOR_ALLOWED_PARENT_DIR=/srv/workspaces node dist/main.js目标机器不需要 lens-core 或其他任何仓库。EXECUTOR_PROTO_PATH、EXECUTOR_PAIR_PATH、EXECUTOR_TLS_* 等环境变量的语义不变;lens-core 侧 config/lens.jsonc 中的 executor.address 需要指向本服务实际监听的地址。
Capability 与校验边界
本服务通过 gRPC 暴露以下能力:Health、Identity、Shutdown、ReadFile、ViewImage、EditFile、PatchFile、RevertCall、DescribeCallHistory、DeleteCallHistory、Glob、Grep、BrowseProject、RunCommand、ListAllowedParent、ValidateWorkspacePaths、OpenInFileManager、ChooseWorkspaceFolder(完整契约见 proto/executor.proto)。
ReadFile/EditFile/PatchFile/RevertCall/DescribeCallHistory/Glob/Grep/BrowseProject通过src/filesystem.ts的WorkspaceFiles做路径校验。普通访问的 Workspace 根目录必须位于允许父目录内,目标必须落在这些根目录内;full_access=true明确放开这些文件路径边界,调用方仍须授权该访问。RunCommand不提供 OS 级沙箱:命令本身可以是任意可执行文件(包括bash/pwsh/sh等解释器),一旦启动,其行为完全由该命令自身决定,不受 Workspace 边界约束——校验只发生在命令的起始cwd上。生产部署必须把这当作等同于该 OS 账户完整文件系统权限的信任边界,不能把 Workspace 校验当作强隔离。EditFile/PatchFile必须带workspace_id和call_id:本服务在写盘之前把修改前后的精确字节记进自己的文件历史(src/history.ts),写盘后再确认该记录。修改前后的内容不回传给lens-core,lens-core也不保存文件字节或对象 id。中途崩溃会留下一条PREPARED记录;原生 Git blob OID 标识修改前后的精确字节;按同一 Git 对象格式计算磁盘内容的 blob OID 即可判定写入是否落盘(COMMITTED/ABORTED),两者都不匹配则记为UNKNOWN且默认不可 revert,本服务不会自动重试。RevertCall按本服务自己的 per-Workspacesequence逆序还原,先对涉及的每个路径做完整 preflight:磁盘上的内容必须仍然是该 Call 写下的那份。任何一处不符就返回conflicts且什么都不写,除非显式传force=true。因此"文件在这之后被别人改过"或"调用方漏掉了链条中间的某个 Call"都会得到拒绝,而不是被覆盖。revert 自身也作为一次变更被记录,所以 revert 可以被 revert。冲突原因有content_changed、unresolved、missing_record、outside_workspace四种;force只豁免前两种,永不豁免outside_workspace——那是 Workspace 边界,不是内容分歧。DescribeCallHistory回答"现在执行RevertCall会发生什么",跑的是同一套模拟但不写任何东西(PREPARED的判定只计算不落盘),因此可以被反复轮询。它的结论是快照:任何后续写入都能让它失效,RevertCall因此总会自己重新模拟。DeleteCallHistory删掉这些 Call 的 ref 并跑一次git gc --prune=now。历史只在调用方显式要求时回收,本服务不自己按时间或体积过期。EditFile/PatchFile按(workspace_id, call_id, 请求摘要)幂等:响应丢失后重发同一个 Call 会直接返回当时记录的响应,不会再写一次文件——edit_file的重试因此不会以 "old_string was not found" 失败。第一次尝试仍在执行时到达的重复请求会被折叠到同一次执行上。同一个call_id配上不同的参数会被拒绝,也不会与并发的另一份参数互相返回对方的答案。- 写入目标是 realpath 解析后的真实路径:通过 symlink 或目录别名写入会落在真实文件上、不会把链接替换成普通文件,同一个文件的不同拼写也只对应一条历史。同一文件的整段读—记录—写由一把按真实路径的锁串行化,
RevertCall也在取 Workspace 锁之前锁住它要写的全部文件,因此并发写与 revert 不会交错,也不会记录出互相矛盾的历史链。 - 正式 ref 只通过 compare-and-swap 移动(先写
refs/lens/staging/,再用一次原子的update-ref批次),所以即使进程锁失效或有人在仓库外动过手,也不会发生丢失更新。 RunCommand不记录这类信息——它能触达的文件范围不确定,lens-core不为其提供 revert。允许git作为依赖并不改变这一点:要知道任意命令碰过哪些文件仍然需要工作树扫描或更强的 OS 级追踪。
Access
Per-Core directory permissions (opt-in)
For one Core per client, set EXECUTOR_DIRECTORY_POLICY_PATH to an administrator-owned JSON file following
config/directory-policy.example.json. This is separate from the browser Pair credential.
Each clients entry binds an approved Core client_id from the Executor pair file to a credential_sha256 and directories entries containing an absolute path and explicit permissions.
Generate an independent service credential for each Core with Node:
node --input-type=module -e "import{randomBytes,createHash}from'node:crypto';const token=randomBytes(32).toString('base64url');console.log('Core credential:',token);console.log('Executor SHA-256:',createHash('sha256').update(token).digest('hex'))"Store only the token in a private file on that Core. Configure Core executor.identityPath and
executor.credentialPath (file path relative to its installation root); restart Core after changing these settings.
Keep the identity stable, manually approve its Executor pair record, then place its client_id, token hash and directories in the policy.
Do not reuse a browser credential or another Core's service credential. A client must pass both pairing and service-credential checks.
The Executor reloads policy on every RPC; removed grants, replaced token hashes and malformed/missing files deny subsequent operations. An in-flight operation keeps its admitted policy snapshot. Setting/unsetting the policy environment variable requires restarting Executor. Empty directory lists grant no file access. Workspace roots must be wholly inside a grant; selecting the shared parent does not confer access. The parent folder browser returns only the client's grant paths, not other clients' folders. Read, image, search and browse operations use canonical Workspace path checks. Permissions inherit into descendants; matching grants combine by union, not most-specific override. Missing permissions deny access:
| Permission | Operations |
| --- | --- |
| list | Browse directories and read directory listings |
| read | Read text files and images |
| search + list | Glob filename search |
| search + read | Grep content search |
| write | Create, edit, rewrite and patch files; no deletion or command execution |
Writes may internally read existing bytes for edits/history but do not authorize reading those bytes back. Search requires its permissions at the starting path and each visited file; select a narrower search root if only a child is authorized.
Legacy string directory entries retain list, read, search only. Empty permissions grant nothing.
full_access, Workspace directory creation, history access/revert, arbitrary commands and OS file dialogs remain disabled. No delete permission is accepted because there is no standalone delete-file RPC.
EXECUTOR_BUSINESS_MODE=true still overrides write grants and enforces read-only operations. Audit queries retain caller scope.
Local first-contact autoapproval and EXECUTOR_AUTO_VERIFY=true are not allowed in directory-policy mode.
Audit/evidence/history/pairing/policy directories must be outside EXECUTOR_ALLOWED_PARENT_DIR.
Do not put Core identity or credential files inside shared directories. Non-loopback deployment requires Executor TLS and trusted TLS configuration on Core.
Secure policy, pairing and credential files with OS permissions; possession of a copied service credential plus its declared identity permits impersonation.
This is not employee authentication or an OS sandbox: a shared-server administrator can bypass it, concurrent malicious filesystem replacement is not prevented,
and revocation cannot recall content already returned to a Core or model. No RPC schema changes are required.
- 原生客户端每次请求提交 MAC 和硬件 ID;浏览器提交浏览器指纹。请求头分别对应
x-lens-client-kind、x-lens-client-mac、x-lens-hardware-id、x-lens-browser-fingerprint。 - 未知客户端会被拒绝;已知客户端的验证状态保存在
config/pair.json(EXECUTOR_PAIR_PATH可覆盖路径),初始值为isVerified=false,需要手动改为true才允许访问。 EXECUTOR_AUTO_VERIFY=true会跳过上述校验,仅用于受控本地开发。- 监听非 loopback 地址时必须启用 TLS(
EXECUTOR_TLS_ENABLED=true及对应的EXECUTOR_TLS_CERT_FILE/EXECUTOR_TLS_KEY_FILE/EXECUTOR_TLS_CA_FILE),否则进程启动时会直接失败。
该机制是本地设备白名单,不是强身份认证;用户负责网络和传输安全。
组件标识与优雅停止
Identity 和 Shutdown 两个 RPC 不经过上述客户端白名单,供外部进程管理程序使用:
Identity -> { component, software_version, protocols, pid, status }
Shutdown -> { status: "stopping", ... },随后进程退出它们没有放进 Health,而是独立的 RPC。原因有两点:Health 需要已验证客户端,而"识别这个进程是什么"正是管理程序在成为已验证客户端之前要做的事,并且必须在 Executor 不健康时(pair 文件本身损坏就是主要场景)仍然可用;同时 Health 会返回 allowed_parent_dir 和 state_dir 两个文件系统路径,把它公开会造成信息泄露。因此 Identity/Shutdown 只返回不含路径、不含客户端标识的字段,Health 语义保持不变。
Shutdown 只接受 loopback 调用方,其余返回 PERMISSION_DENIED:监听地址可能在 LAN 上,而该 RPC 不受 pair 文件保护,未加限制就等于一个任何人可触发的远程停止。SIGINT/SIGTERM 仍然可用于交互式终端,但不是对外声明的停止机制。
software_version 和 protocols 的唯一来源是 package.json(顶层 version 与 lens 段),因此管理程序在进程未运行时也能读到同样的值。core-executor-rpc 当前为 8,必须与 lens-core 声明的整数严格相等;Core 和 Executor 需要同时更新到匹配的协议版本。
许可证
本项目使用 MIT License。
持久化载荷格式
Evidence 和审计先保存原始字节。Executor 进程拥有一个后台维护队列,每分钟检查一次;某个文件连续 30 分钟没有写入或业务读取后,才使用 Node 原生异步 zstd(level 19、frame checksum)冷却。全进程最多运行一个维护任务,压缩在线程池执行。重启会为现有可维护文件重新给予 30 分钟宽限期。共享审计目录中仅冷却自己的活动段、封存段,或能以 ESRCH 确认原 PID 已退出的遗留活动段;没有 owner 的旧活动段、EPERM 和 PID 复用均保守跳过。压缩数据直接保存为二进制,协议图片和分页游标的 Base64 编码不受影响。
| 存储 | 物理格式与读取合同 |
|---|---|
| Evidence | <id>.blob 保存返回字节,<id>.receipt.blob 保存 receipt JSON。16 字节头为 ASCII LENSOBJ2、uint32LE encoding(0=raw、1=zstd)、uint32LE 原文长度。冷热替换保持路径稳定。先 payload fsync,再 receipt fsync,SHA-256 和大小始终描述原文。单个对象最多 64 MiB 原文。 |
| 审计 | <segment-id>.p<PID>.active.zlog(活动段)或 <segment-id>.p<PID>.zlog(封存段)。8 字节 ASCII LENSZLG2 后重复 12 字节块头(stored length、original length、encoding,均为 uint32LE)和相应正文。每块原文最多 4 MiB,JSONL 记录可以跨块;前台追加使用 raw,冷却后可混合 raw 与 zstd 块。 |
| 文件历史 | Git tree 中的 meta.json 保存原始 JSON,<ordinal>.before、<ordinal>.after 保存修改前后的原始字节。Git 负责原生 blob、pack/delta、refs、CAS、可达性与 GC,不添加额外 zstd。 |
审计 append 串行执行,整条记录所有块写完并 fsync 后才确认成功。关闭仅将活动段重命名为封存段,不立即压缩。后台冷却在写入队列之外生成临时文件,发布时重新核对活动 generation 和源文件身份,并进入同一写入队列,避免覆盖新追加记录。Evidence 也在发布前核对源文件;文件被删除或替换时放弃旧压缩结果。后台维护不新增删除或保留策略,进程关闭时停止调度并等待正在运行的工作退出。
冷热转换保持逻辑字节、segment、sequence、hash chain 和规范化游标身份。活动段因崩溃留下的不完整末尾块或末尾 JSONL 记录报告 incomplete_tail;封存段残缺尾部、无效编码、原文长度错误及冷块 checksum 错误报告 integrity 错误。查询仍按原文字节限制 32 MiB 扫描、1 MiB 返回;审计查询会更新所读取段的活动时间。Evidence 和审计的冷数据可直接读取,读取不要求等待预热。对象头和正文使用同一个文件句柄,避免路径替换导致混读。
格式切换不执行自动迁移或删除。请为新版本使用新的 Evidence 和审计目录并保留旧目录备份;原生 Git version 3 历史可继续使用。zstd Git history version 2、无标记旧 Git history、Evidence .bin/.json/.zst 以及旧审计 .jsonl/LENSZLG1 会被拒绝。配置、配对信息、状态身份、索引、序号和工作区原文件保持原有格式。
构建后可只读解码冷热对象或日志,输出原始二进制字节或审计 JSONL:
node dist/decode-storage.js snapshot.blob > snapshot.bin
node dist/decode-storage.js segment.zlog > segment.jsonlGit 历史使用原生 cat-file blob <oid> 或 show <ref>:meta.json 读取。对象解码最多 64 MiB,日志 CLI 单次解码最多 256 MiB;日志 CLI 拒绝不完整的物理块,审计查询额外检查逻辑记录和哈希链。解码文件可能包含敏感原文,按原数据的访问规则保存。验证命令为 pnpm test、pnpm run typecheck、pnpm run lint、pnpm run build;每次构建成功后运行 pnpm run betterleaks,扫描当前可提交文件(含新增未跟踪文件),输出使用 --redact。
