refactor(agent): replace OMP with Claude Code kernel
This commit is contained in:
1 parent
0fb2226f8c
commit
9a1f182397
232 files changed
+2561
-25564
No files matched your search
@@ -9,10 +9,3 @@ release/
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.local
|
||||
resources/sandbox/win32-x64/*.exe
|
||||
resources/sandbox/win32-x64/manifest.json
|
||||
resources/sandbox/win32-x64/git/
|
||||
resources/omp/omp.exe
|
||||
resources/omp/LICENSE
|
||||
resources/omp/THIRD-PARTY-NOTICES.txt
|
||||
resources/omp/*.part
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
安装程序使用 NSIS,Windows x64、当前用户安装,可选择安装路径,提供桌面和开始菜单快捷方式,卸载不删除用户数据。没有配置代码签名证书,因此属于未签名的本地测试分发版本。
|
||||
|
||||
包含应用主进程、preload、两份 renderer、生产依赖、图标、models.dev 元数据、原生沙盒0.7.0、内置Git资源,以及内置 OMP 18.6.3。模型Key、个人会话、项目和用户专家包都在用户数据目录中,不复制到分发包。新用户需自行配置模型;本机已有数据继续沿用。
|
||||
包含应用主进程、preload、两份 renderer、生产依赖、图标、models.dev 元数据和内置 OMP 18.6.3。模型Key、个人会话、项目和用户专家包都在用户数据目录中,不复制到分发包。新用户需自行配置模型;本机已有数据继续沿用。自建 AppContainer 沙盒已于 2026-10-20 移除,不再作为分发包内容。
|
||||
|
||||
OMP 文件位于安装目录 `resources/omp/`,包含 `omp.exe`、MIT LICENSE、THIRD-PARTY-NOTICES.txt 和 runtime.lock.json。运行时依次支持 `YCODE_OMP_PATH` 显式覆盖、包内 OMP、旧 Ygent 安装和 HTTP 回退;正常安装不依赖外部 Ygent。新设备未预置本机的五个自定义专家或模型配置。
|
||||
|
||||
构建与实际包启动验证结果:OMP资产摘要、版本输出和真实RPC ready握手通过;最终安装程序246,498,049字节(235.08MiB),SHA-256为`4a8ceaeb1dd12bfc228911c2d586a7db2f4682b02b995c62602e7b14e6830184`。安装程序归档确认包含四个OMP文件;实际签名状态NotSigned。
|
||||
|
||||
正式YCode.exe包内容与启动共10项检查通过:版本、main/preload/renderer、生产依赖、本机配置与会话排除、模型元数据/内置OMP/沙盒/Git及摘要、真实exe启动和生产IPC、空用户数据隔离、0未捕获renderer异常及正常退出。生产IPC实际选择的OMP路径为包内resources/omp/omp.exe。报告`.tmp/release-v001-mfDVCw/report.json`,汇总`.tmp/release-v001-verification.json`;已查看实际客户端截图`packaged-client.png`。离屏启动使用软件渲染,不据此声称GPU硬件加速路径通过。
|
||||
正式YCode.exe包内容与启动共10项检查通过:版本、main/preload/renderer、生产依赖、本机配置与会话排除、模型元数据/内置OMP及摘要、真实exe启动和生产IPC、空用户数据隔离、0未捕获renderer异常及正常退出。生产IPC实际选择的OMP路径为包内resources/omp/omp.exe。报告`.tmp/release-v001-mfDVCw/report.json`,汇总`.tmp/release-v001-verification.json`;已查看实际客户端截图`packaged-client.png`。离屏启动使用软件渲染,不据此声称GPU硬件加速路径通过。上述是移除沙盒前的历史验证结果;移除后的构建验证见移除记录。
|
||||
|
||||
交付附`release/使用说明-v0.01.txt`及`release/SHA256SUMS.txt`。验证在隔离用户目录执行,不调用用户模型接口,不改当前开发客户端会话;包内可执行文件启动检查不等于已在干净Windows设备验收完整安装/卸载、GPU兼容和所有功能。
|
||||
@@ -1,5 +1,7 @@
|
||||
# 子智能体文件租约:并发工具调用修复(2026-10-18)
|
||||
|
||||
> **已被 2026-10-20 移除取代:** 用户已要求移除文件所有权租约。[移除记录](文件所有权租约移除记录-2026-10-20.md)。当前调度器不再维护 `leases`、`rootIntents`、`rootLeases` 或等待队列;父子、兄弟调用和多个子智能体可同时声明同一文件。本文保留为历史实现与问题背景,不代表当前行为。
|
||||
|
||||
## 用户可见问题
|
||||
|
||||
- 同一回合里模型并发发出多条命令时,其中一条会返回「相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。」,但当时没有任何别的任务在改文件,命令本身也大多是只读的(`dir`/`findstr`)。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 1. 目标与发布边界
|
||||
|
||||
本流程用于生成可分发的 Windows 安装程序和免安装目录包,并确保内置 OMP、沙盒、Git、模型元数据和第三方许可完整。
|
||||
本流程用于生成可分发的 Windows 安装程序和免安装目录包,并确保内置 OMP、模型元数据和第三方许可完整。
|
||||
|
||||
发布包不得包含:
|
||||
|
||||
@@ -166,12 +166,11 @@ npm run dist:installer
|
||||
|
||||
1. omp:vendor:恢复并校验内置 OMP。
|
||||
2. npm run build:
|
||||
- 构建原生沙盒
|
||||
- TypeScript node/web 检查
|
||||
- electron-vite main/preload/renderer 构建
|
||||
3. electron-builder --win nsis:
|
||||
- 生成 release\win-unpacked
|
||||
- 复制 OMP、模型元数据、沙盒和 Git extraResources
|
||||
- 复制 OMP、模型元数据和图标 extraResources
|
||||
- 生成 NSIS 安装程序
|
||||
|
||||
仅需免安装目录时:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# YCode 接手状态(2026-10-15)
|
||||
|
||||
> 2026-10-20 文件所有权租约移除:[范围与验证](文件所有权租约移除记录-2026-10-20.md)。按用户要求删除子智能体调度器的 `leases`、`rootIntents`、`rootLeases` 和 root 等待队列;主智能体工具、子智能体工具以及父子同文件写入不再因所有权被拒绝或串行。保留 `spawn_agent` 的声明文件列表校验,`write` 模式仍只能写自己声明的路径。`npm run typecheck`、`npm run test:subagents`(31 项)和完整 `npm run build` 通过。移除后并发写同一文件按工具实际执行顺序生效,无跨工具锁;需要原子一致性时由调用方或更高层流程处理。
|
||||
|
||||
> 2026-10-20 自建 AppContainer 沙盒移除:[范围与验证](沙盒移除记录-2026-10-20.md)。按用户要求删除 `src/main/sandbox`、`native/sandbox`、`resources/sandbox`、`sandbox_workspace`/`sandbox_git` 工具、相关构建/测试/维护脚本、打包 extraResources 和忽略规则;保留 Electron `webPreferences.sandbox` 渲染进程隔离及“默认权限/完全访问”权限界面。`npm run typecheck` 与 `npm run build` 通过,`out/main/index.mjs` 不再包含 `windows-appcontainer`、`sandbox_workspace`、`sandbox_git`、`ycode-sandbox`;`release/win-unpacked/resources/sandbox` 已清理。本仓库不再提供自建 OS 级沙盒;此前所有沙盒验收结果只作为历史记录,不代表当前代码能力。
|
||||
|
||||
> 2026-10-18 子智能体文件租约并发修复:[实现、证据与限制](子智能体文件租约并发修复-2026-10-18.md)。修复同一 assistant 回合内并发宿主工具调用被误判为「相关文件正由其他任务修改」的问题:主智能体侧新增按足迹的先到先得等待队列(显式写工具取文件路径,命令等未知足迹取工作区根),root↔root 排队而非报错,等待有 10 分钟上限并随取消/超时推进队列;root 显式写↔子智能体声明文件、子智能体声明文件↔主智能体正在写的文件双向仍拒绝并给出可执行文案,子智能体↔子智能体保持 spawn 拒绝。用户会话 `39965c03` 原生转录显示两条命令相隔 1 ms 同时启动、后者被判冲突,属竞态。独立子代理评审发现并修掉父→子方向互斥缺失(P1)与出队后不推进队列(P2);`close()` 不清理 root 队列(P3)已记录未修。`npm run test:subagents` 40 项、`npm run test:subagents:app` 28 项(真实 Electron/OMP,0 renderer 错误)、typecheck、完整 build 通过。未重启用户安装版;未知足迹工具与子智能体租约不再互斥为有意取舍;`close()` 对卡死 worker 的等待仍无上限。
|
||||
|
||||
> 2026-10-18 v0.01 Windows测试分发包:[构建与限制](分发包-v0.01-2026-10-18.md)。用户指定文件名版本v0.01,内部SemVer改0.0.1;NSIS按用户安装、可选目录、保留卸载数据,产物`release/YCode-v0.01-Setup-x64.exe`(140.3MiB),附说明和SHA256SUMS。完整dist:installer及实际包内容/正式exe隔离启动10项通过,0未捕获renderer异常,已查看截图;报告`.tmp/release-v001-M4MP5b/report.json`。未签名、未测干净设备安装卸载/GPU全路径;OMP仍外部探测,个人配置/专家不随包供应。保留其他工作区修改,未提交、未外部发布。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 文件所有权租约移除记录(2026-10-20)
|
||||
|
||||
## 范围
|
||||
|
||||
按用户要求移除子智能体调度器中的文件所有权租约,包括:
|
||||
|
||||
- 模块级 `leases`:子智能体声明文件的独占归属
|
||||
- 模块级 `rootIntents`:主智能体写入意图
|
||||
- 模块级 `rootLeases`、`rootWaiters` 与 10 分钟等待队列
|
||||
- `acquire` / `release` 租约生命周期
|
||||
- `rootExecute` 中按路径或工作区根排队、拒绝冲突的逻辑
|
||||
- 子智能体工具执行时对 `leases.get(path) === entry.view.id` 的归属检查
|
||||
|
||||
## 保留行为
|
||||
|
||||
- `spawn_agent` 的 `write` 模式仍要求声明 1 至 32 个明确文件路径。
|
||||
- 子智能体写文件时仍只能使用 `write_file` / `edit_file`,且路径必须属于自己声明的文件。
|
||||
- 读工具、子智能体并发上限、取消、超时、结果汇总和权限模式门禁保持不变。
|
||||
- 文件工具自身的路径边界、写前重读和原子写入逻辑保持不变。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 主智能体同一回合的多个工具调用直接执行,不再按文件足迹先到先得排队。
|
||||
- 父智能体与子智能体可以同时声明或写入同一文件,不再返回“文件正由子智能体修改”或“文件已分配给另一个子智能体”。
|
||||
- 多个子智能体可以声明同一文件;最终内容取决于各写入工具的实际执行顺序。
|
||||
- 同一文件的并发写不再由调度器串行化,也不提供跨工具锁或自动合并。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run test:subagents`:31 项通过,覆盖同文件声明、父写与子写、并发命令、写入范围拒绝、回退记录、取消和超时。
|
||||
- `npm run build`:通过。
|
||||
- `src/main/subagents/scheduler.ts`:未发现 `leases`、`rootIntents`、`rootLeases`、`rootWaiters` 或 `acquireRootLease`。
|
||||
|
||||
## 限制
|
||||
|
||||
- 并发写同一文件时可能发生覆盖;需要互斥或合并的场景必须由调用方另行处理。
|
||||
- 回退记录要求同一文件连续修改时前一次 `newContent` 与后一次 `oldContent` 一致;并发交错写同一文件可能使回退校验失败。
|
||||
- 历史文档 `子智能体文件租约并发修复-2026-10-18.md` 仅作为旧实现记录保留,不代表当前行为。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 自建 AppContainer 沙盒移除记录(2026-10-20)
|
||||
|
||||
## 范围
|
||||
|
||||
按用户要求移除 YCode 自建的 Windows AppContainer / Job Object 执行沙盒,包括:
|
||||
|
||||
- 主进程沙盒实现:`src/main/sandbox`
|
||||
- 原生执行器与文件/Git/工作区组件:`native/sandbox`
|
||||
- 随包资源与内置 MinGit:`resources/sandbox`
|
||||
- 工具适配:`src/main/agent/tools/sandbox-git.ts`、`src/main/agent/tools/sandbox-workspace.ts`
|
||||
- 共享契约:`src/shared/sandbox.ts`
|
||||
- 构建、供应、维护和回归脚本:`scripts/build-sandbox.mjs`、`scripts/vendor-sandbox-git.mjs`、`scripts/verify-sandbox-app.cjs`、`scripts/sandbox-*`
|
||||
- `package.json` 中 `sandbox:build`、`test:sandbox:*`、`sandbox:maintain` 与沙盒 extraResources
|
||||
- `.gitignore` 中沙盒产物规则
|
||||
- `release/win-unpacked/resources/sandbox` 本地构建产物
|
||||
|
||||
## 保留项
|
||||
|
||||
- Electron 渲染进程隔离继续使用 `webPreferences.sandbox`、`contextIsolation` 和无 `nodeIntegration`。
|
||||
- 对话页“默认权限 / 完全访问”开关保留,因为它是现有权限交互契约;`default-sandbox` 只是枚举名称,不代表仍有自建 AppContainer 后端。
|
||||
- 历史沙盒设计、验收和取证文档未删除,仍可作为先前工作记录;其中的能力结论不再适用于当前代码。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- `npm run build` 不再编译原生沙盒,也不再要求 Visual Studio C++ Build Tools。
|
||||
- 安装包与免安装目录不再包含 `ycode-sandbox.exe`、沙盒 Node/Python 适配器或内置 MinGit 资源。
|
||||
- `execute_command` 继续使用宿主 Shell;YCode 不再提供自建 OS 级命令、文件写回、Git 或长期工作区隔离。
|
||||
- 普通文件工具原有的工作区路径约束和变化检查不受影响。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run build`:通过(TypeScript、Electron main/preload/renderer)。
|
||||
- `out/main/index.mjs`:未发现 `windows-appcontainer`、`sandbox_workspace`、`sandbox_git`、`ycode-sandbox`、`executeSandboxCommand`、`sandboxStatus`。
|
||||
- `src`、`scripts`、`package.json`、`.gitignore`:未发现上述自建沙盒标识。
|
||||
- `git status` 显示相关受控文件均为删除;唯一既有未跟踪文件为 `in`,未修改。
|
||||
|
||||
## 限制
|
||||
|
||||
- 本次没有重新生成 NSIS 安装包;发布前需要重新执行 `npm run dist:installer`,并核对新产物不包含 `resources/sandbox`。
|
||||
- 历史 `.tmp` 测试记录和用户数据目录中可能残留旧沙盒测试状态,不参与当前运行,也不会被打包。
|
||||
- 移除 AppContainer 后,若需要操作系统级强制隔离,应作为新方案重新设计和取证,不能引用本仓库旧沙盒结论。
|
||||
@@ -10,7 +10,7 @@ const root = process.cwd()
|
||||
*/
|
||||
// node-pty resolves its shipped native/prebuild resources relative to its own package.
|
||||
// Preserve that package boundary instead of moving its loader into out/main.
|
||||
const builtinsExternal = ['electron', 'node-pty']
|
||||
const builtinsExternal = ['electron', 'node-pty', '@anthropic-ai/claude-agent-sdk']
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
# YCode sandbox, protocol 1
|
||||
|
||||
This directory contains YCode's Windows x64 execution backend. The implementation
|
||||
uses public Windows APIs and the Microsoft SDK. WorkBuddy binaries, services,
|
||||
profiles, hooks and IPC pipes are not runtime or build dependencies.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Prerequisites: Windows x64, Node/npm, Visual Studio C++ Build Tools, and a Windows
|
||||
10/11 SDK. The build discovers the installed compiler with `vswhere`; it does not
|
||||
install or modify the toolchain.
|
||||
|
||||
```powershell
|
||||
npm run sandbox:build
|
||||
npm run test:sandbox
|
||||
npm run sandbox:maintain -- status
|
||||
npm run sandbox:maintain -- self-test
|
||||
npm run test:sandbox:app
|
||||
npm run test:sandbox:policy
|
||||
npm run test:sandbox:toolchains
|
||||
npm run test:sandbox:directories
|
||||
npm run test:sandbox:write
|
||||
npm run test:sandbox:workspace
|
||||
npm run test:sandbox:feasibility
|
||||
npm run test:sandbox:stdio
|
||||
```
|
||||
|
||||
`sandbox:build` compiles `runner.cpp` with MSVC C++17, `/W4 /WX /MT /Brepro`.
|
||||
Generated artifacts are `resources/sandbox/win32-x64/ycode-sandbox.exe` and
|
||||
`manifest.json`. The manifest records the source/binary SHA-256, compiler, SDK,
|
||||
architecture and protocol. Generated binaries are ignored by Git. `npm run build`
|
||||
builds the runner before checking TypeScript and building Electron. Packaging
|
||||
copies the runner, pinned adapters/manifests and bundled Git; test-only probe
|
||||
executables are excluded.
|
||||
|
||||
`test:sandbox` executes real native processes in test-owned temporary directories.
|
||||
`test:sandbox:app` builds and runs the actual production main/preload/renderer,
|
||||
real OMP RPC, and a deterministic localhost model. It uses separate home/userData,
|
||||
hides its own windows, and exercises approval, rejection and cancellation.
|
||||
|
||||
## Ownership and execution
|
||||
|
||||
- `runner.cpp`: AppContainer profile, SID/token verification, explicit stdio
|
||||
handle inheritance, restricted child environment, Job, timeout/parent monitor,
|
||||
temporary drive, lease and native result protocol.
|
||||
- `src/main/sandbox/policy.ts`: workspace containment, sensitive names,
|
||||
link/device/alternate stream checks.
|
||||
- `src/main/sandbox/service.ts`: executable identity, bounded workspace snapshot,
|
||||
native process lifecycle, diff, approval and audit.
|
||||
- `workspace-state.h` / `workspace.ts` / `workspace-service.ts`: host-only ACL
|
||||
sealing, persistent session drafts, immutable checkpoints, synchronization,
|
||||
approval-based commit and explicit recovery.
|
||||
- `src/main/sandbox/transactions.ts`: independent before/after backups, batch
|
||||
conflict checks, compensation and explicit recovery.
|
||||
- `src/main/sandbox/maintenance.ts`: lease validation, orphan cleanup and record
|
||||
inventory.
|
||||
- `policy-store.ts` / `authorizations.ts`: versioned global/workspace policy,
|
||||
compare-and-swap updates, single-use approval and policy revocation.
|
||||
- `toolchains.ts` / `dependency-cache.ts`: explicit content-verified runtime imports
|
||||
and immutable workspace/lock/runtime-bound dependency layers.
|
||||
- `execution.ts`: bounded admission and sequential execution per workspace.
|
||||
- `src/main/agent/conversation-tools.ts`: common OMP and fallback execution path.
|
||||
|
||||
Default `execute_command` creates a fresh snapshot and unique
|
||||
`YCode.Run.<32 hexadecimal characters>` AppContainer profile. No network
|
||||
capabilities are granted. The runner verifies the suspended child's AppContainer
|
||||
flag and exact SID, assigns the Job, then resumes it. Only stdio handles are
|
||||
inherited. The Job forbids breakaway, limits active processes to 32 and aggregate
|
||||
committed memory to 1 GiB, and kills remaining descendants when the root finishes.
|
||||
The native runner also watches its launch parent and accepts `cancel\n` on stdin.
|
||||
|
||||
A free per-user DOS drive maps to this generated stage. This makes drive-root
|
||||
queries by cmd and PowerShell operate on the scratch root. The allocator has a
|
||||
named mutex and uses exact-target removal. The mapping and profile are removed
|
||||
before a successful native result. The original workspace ACL is not changed.
|
||||
|
||||
The child can modify its scratch copy. Only a successful command can propose host
|
||||
file changes. A real conversation approval precedes writeback. All targets are
|
||||
checked against the initial hashes and rechecked while committing. Independent
|
||||
backups are persisted before the first host write. Rejected, failed, cancelled,
|
||||
conflicting or invalid changes are not committed. A failed compensation is
|
||||
reported with its transaction ID and `recovery-required` state. Audit failure
|
||||
after a successful commit preserves the actual committed outcome.
|
||||
|
||||
## State and maintenance
|
||||
|
||||
Production state is under `app.getPath('userData')/sandbox`. The actual app API
|
||||
`window.ycode.app.sandboxStatus()` reports installed/unavailable backend identity;
|
||||
`installed` means the binary manifest is valid. A native self-test or execution
|
||||
is required to verify that the OS can start the isolation boundary.
|
||||
|
||||
Each `runs/<uuid>/` contains request metadata, native `lease.json`/`result.json`,
|
||||
`audit.json` or `failure.json`, and `cleanup.json`. Raw stdout/stderr and environment
|
||||
credentials are excluded from the audit. Scratch stages are removed after runs;
|
||||
cleanup status records a failure if the OS still holds a file. The `transactions/`
|
||||
directory retains the manifest and independent `.before`/`.after` contents.
|
||||
|
||||
Stateful maintenance requires an explicit absolute state directory. It does not
|
||||
guess or switch to another application's storage.
|
||||
|
||||
```powershell
|
||||
npm run sandbox:maintain -- audit --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- transactions --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- restore TRANSACTION_UUID --workspace 'C:\absolute\workspace' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- cleanup --state-root 'C:\absolute\userData\sandbox'
|
||||
```
|
||||
|
||||
Recovery checks the expected workspace, backup hashes, and current file hashes.
|
||||
It refuses to overwrite a later external edit. Interrupted prepared/applying
|
||||
transactions remain inspectable and recoverable. Restoring an already restored
|
||||
transaction is harmless. `cleanup` checks a lease's run identity and PID, skips
|
||||
live runners, and removes an orphaned profile/drive only for the recorded exact
|
||||
target. It preserves transaction backups and audit history.
|
||||
|
||||
## Policies, runtimes and dependencies
|
||||
|
||||
Policy defaults preserve the original snapshot boundary. Maintenance can tighten
|
||||
entry/file/byte limits, disable commands, deny writeback and add relative denied
|
||||
paths. Workspace overrides cannot relax global restrictions. Updates require the
|
||||
current revision and an exclusive policy lock. Corrupt/unknown policies fail
|
||||
closed. Policy changes cancel active commands and pending conversation approvals;
|
||||
an approved ticket is scoped to its exact change batch and can be consumed once.
|
||||
Bindings include a content fingerprint as well as the revision, so manual edits
|
||||
that keep a revision do not preserve old permissions or self-test observations.
|
||||
|
||||
```powershell
|
||||
npm run sandbox:maintain -- policy --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- policy-set --expected-revision REVISION --rules 'C:\absolute\rules.json' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- toolchain-add node --source 'C:\absolute\node.exe' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- toolchain-add python --source 'C:\absolute\python.exe' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- toolchain-add git --source 'C:\Program Files\Git\cmd\git.exe' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- git-state --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- git-reset --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- dependency-add node --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- dependency-add python --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- operation RUN_UUID --state-root 'C:\absolute\userData\sandbox'
|
||||
```
|
||||
|
||||
Import selects explicit local runtime bytes; it never executes the source
|
||||
interpreter on the host. Node includes the installed sibling npm distribution
|
||||
when present. Python includes its version DLL, standard library, extension DLLs
|
||||
and installed pip when present. Sensitive configuration and test/cache files are
|
||||
filtered; no global PATH or credentials are inherited. Imported runtimes have
|
||||
independent file-count/size limits, manifests and pinned hashes. Registration is
|
||||
metadata availability; every requested file is verified before actual execution.
|
||||
|
||||
Model commands select `runtimes: ['node']`, `['python']` or `['git']`. A dependency import
|
||||
reads only that workspace's `node_modules` or `.venv/Lib/site-packages`, rejects
|
||||
links and hard-linked files, and binds the layer to workspace, project/lockfile
|
||||
hashes and runtime identity. Commands opt in with `dependencies: ['node']` or
|
||||
`['python']`. Cached dependencies are copied to the private stage and protected by
|
||||
native read/execute ACLs. Node keeps the actual node_modules layout for CommonJS
|
||||
and ESM. Python uses a private PYTHONPATH. Dependency contents are never promoted
|
||||
as ordinary generated changes; cache updates require explicit import. A changed
|
||||
lockfile/runtime or a corrupted cache rejects execution. Original dependency
|
||||
trees may change after import; the pinned layer changes only on explicit reimport.
|
||||
|
||||
The following paragraph records the original 0.4.0 Git-only snapshot scope;
|
||||
host-Git import/writeback and transport were added in component 0.7.0 below.
|
||||
|
||||
Git is bundled by default; users do not install or register it. Maintainers run
|
||||
`npm run sandbox:vendor-git` once when preparing a source checkout. The pinned
|
||||
official MinGit archive and deterministic runtime manifest are checked before
|
||||
an offline normal build; licenses and source-provenance notices ship with it.
|
||||
An optional explicit import overrides the bundle, without silent fallback if
|
||||
that override is damaged. The importer reads the real MinGW/UCRT x64 image and
|
||||
its PE DLL dependency closure without executing source programs on the host.
|
||||
In the original 0.4.0 scope, Git used a persistent private repository
|
||||
whose initial commit contains the filtered workspace snapshot. Host `.git`,
|
||||
history, index, credentials and hooks are never imported or written back. Thus
|
||||
private status/diff/log do not describe the original host repository. Repository
|
||||
changes require approval, bind the exact candidate and policy, and reject
|
||||
approval-time workspace edits. Read-only status/diff/log do not save stat caches.
|
||||
Git metadata uses its own versioned state outside the worktree; normal file
|
||||
writeback retains its existing independent transaction and recovery record.
|
||||
`git.persistence` and durable operation queries report private publication
|
||||
separately: the two steps are not one crash-atomic transaction. All protocols are
|
||||
disabled by default and the native Job retains zero network capabilities.
|
||||
This historical snapshot path remains available as a separate mode. See
|
||||
`docs/沙盒Git接入-2026-10-18.md` for the original subset.
|
||||
Bundling, copied/packaged-resource and offline reproducibility checks use
|
||||
`npm run test:sandbox:bundled-git`; details are recorded in
|
||||
`docs/沙盒内置Git分发-2026-10-18.md`.
|
||||
|
||||
### Git host and remote integration (component 0.7.0)
|
||||
|
||||
The complete common Git path is documented in `docs/沙盒Git完整接入-2026-10-18.md`
|
||||
and its acceptance matrix. `sandbox_git` can explicitly import a real host
|
||||
repository (including an existing linked worktree), run the supported local Git
|
||||
workflow in the AppContainer draft, and publish project files plus safe Git
|
||||
metadata through coordinated native transactions. `sandbox_workspace` routes
|
||||
commit/discard/recover through the same host-Git binding when one exists.
|
||||
|
||||
Import is approval-bound and preserves real objects, HEAD, index, refs, tags,
|
||||
stash state and allowed configuration without executing host hooks, aliases,
|
||||
filters, credential helpers or external object paths. New repositories remain
|
||||
unborn until a real first commit. Clone/fetch/pull/push use the trusted host
|
||||
transport control plane with fixed Git, explicit HTTPS/SSH credentials, TLS and
|
||||
known-host verification, per-target approvals and durable unknown outcomes.
|
||||
The ordinary command Job still has no network capability. `git-cleanup` removes
|
||||
only abandoned control roots after checking both the host owner record and native
|
||||
lease. This is a Windows x64 engineering acceptance, not a claim of complete
|
||||
Git subcommand compatibility, crash-atomic multi-root publication, or WorkBuddy
|
||||
1:1 equivalence.
|
||||
|
||||
### Persistent session drafts
|
||||
|
||||
Component 0.6.0 adds `workspaceState: 1`. Commands explicitly select
|
||||
`workspace: 'persistent'` to retain a project/session-bound draft. A successful
|
||||
command saves private files without writing the host. `sandbox_workspace`
|
||||
supports `status`, `changes`, `commit`, `discard` and `recover`, with an optional
|
||||
`expectedRevision`. Commit uses the existing conversation approval and native v3
|
||||
host transaction. Explicit `snapshot` or `persistent` selection always confines
|
||||
the command, even in a full-access tool context. Omitting the selector retains
|
||||
the existing permission behavior.
|
||||
|
||||
Normal successful commands retain the same physical NTFS worktree identity by
|
||||
moving it into and out of the run stage. The host-only `--seal-workspace` entry
|
||||
uses relative native directory handles to remove former AppContainer access
|
||||
before retention. Private hash-addressed blobs and revision manifests remain
|
||||
host-only, including for later Jobs from the same session. Manifests are capped
|
||||
at 8 MiB before publication. Root identity and policy changes make old drafts
|
||||
stale. Three-way synchronization imports unrelated host edits and rejects file
|
||||
or ancestor conflicts; file/directory type swaps are currently rejected.
|
||||
|
||||
Failed, cancelled and timed-out commands restore the saved checkpoint. Explicit
|
||||
recovery checks the saved run owner, dead host lock and native lease. Commit
|
||||
intents distinguish an interrupted private update from actual host writes:
|
||||
metadata failure after host commit still reports `committed`. The read-only
|
||||
status path returns `busy` during execution or approval instead of queueing.
|
||||
|
||||
Persistent Git uses its own project/session namespace. Local private Git commits
|
||||
need no host approval and do not modify host `.git`. File checkpoint and private
|
||||
Git publication are separate steps; publication failure is reported separately.
|
||||
Discard archives both the draft and its active private Git reference. Scoped
|
||||
stale Git locks are checked during explicit draft recovery. The existing
|
||||
project-wide snapshot Git behavior is unchanged.
|
||||
|
||||
```powershell
|
||||
npm run sandbox:maintain -- workspace-changes --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox'
|
||||
npm run sandbox:maintain -- workspace-commit --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox' --draft-revision REVISION_UUID --approve
|
||||
npm run sandbox:maintain -- workspace-recover --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox'
|
||||
```
|
||||
|
||||
The CLI also accepts `workspace-status` and `workspace-discard`. Preload exposes
|
||||
read-only `app.sandboxWorkspace(sessionId)`; main resolves the root from the saved
|
||||
session and requires the trusted top-level app page. Ordinary file tools and the
|
||||
read bridge still access the host project; use persistent commands for draft
|
||||
file access. Processes, home, temp, tools and absolute execution paths are not
|
||||
persistent. Mutable dependencies and online installs remain pending; imported
|
||||
read-only dependencies detach before checkpointing. See
|
||||
`docs/沙盒长期工作区接入-2026-10-18.md` for verification and recovery limits.
|
||||
|
||||
Windows derives an additional private AppContainer TEMP directory from
|
||||
LOCALAPPDATA. The runner provisions that profile-specific path before launch;
|
||||
Node/Python temporary files remain inside the private stage and are cleaned with it.
|
||||
Python 3.13+ gives mkdir(0o700) a user/admin-only DACL, excluding the package SID.
|
||||
The pinned, read-only Python site adapter uses inherited native private ACLs for
|
||||
that mode only within the owned stage. Outside paths retain Python's behavior and
|
||||
OS denial. Isolated/no-site Python flags can bypass this compatibility adapter.
|
||||
For synchronous Node subprocess capture, explicitly preload the owned adapter:
|
||||
|
||||
```text
|
||||
node --require "%YCODE_SANDBOX_PROCESS%" build.cjs
|
||||
```
|
||||
|
||||
The manifest-pinned `node-process.cjs` replaces public spawnSync/execFileSync/
|
||||
execSync (including ESM named exports) with a file-descriptor capture adapter.
|
||||
Subprocesses still use Node's own spawn and inherit the same token/Job. Inputs
|
||||
and maxBuffer are capped at 16 MiB; maxBuffer defaults to 1 MiB. Buffer overflow
|
||||
is detected after completion, not an OS hard disk quota or a claim of early
|
||||
termination. Scratch handles/files are closed/removed after each call. Explicit
|
||||
non-pipe stdio configurations pass through. Default Node synchronous pipes still
|
||||
hang on the tested AppContainer without the adapter; async spawn/fork/IPC and
|
||||
comprehensive API compatibility remain separate work. Run
|
||||
`npm run test:sandbox:process` for the bounded Node/Python regression.
|
||||
|
||||
Native operation outcomes are saved independently of verbose audit and chat
|
||||
messages. The operation query returns an unknown/incomplete outcome when no
|
||||
durable host result exists; native process completion does not imply host commit.
|
||||
Self-tests persist their timestamp and exact binary/policy identity. A new binary
|
||||
or policy invalidates old observations. The current self-test verifies command
|
||||
startup/isolation and cleanup, not every network/filesystem/registry capability.
|
||||
|
||||
The internal `stdioProbe` mode remains experimental and accepts bounded echo
|
||||
requests only. It uses the already bound suspended/verified child and its Job's
|
||||
inherited pipes, rejects caller-supplied identities, repeated IDs and excessive
|
||||
frames/concurrency, and forwards replies as bounded base64 control records.
|
||||
Model-facing tool arguments cannot enable it. It grants no host file/network
|
||||
operation and is not individual descendant PID authentication. Normal commands
|
||||
retain NUL stdin. Named-pipe connectivity remains unverified on this host; run
|
||||
`npm run test:sandbox:feasibility -- --probe-pipe` to record the experimental
|
||||
observation, or add `--require-pipe` to require actual success. An unavailable
|
||||
transport is reported as unavailable, not as a completed broker capability.
|
||||
|
||||
### Native read-only workspace bridge
|
||||
|
||||
Commands may explicitly select `bridge: 'workspace-read'` together with
|
||||
`runtimes: ['node']`. The manifest-pinned client is available at
|
||||
`process.env.YCODE_SANDBOX_BRIDGE`; `require(...).connectSync()` supplies
|
||||
`readFileSync`, `statSync`, `readdirSync`, `querySync` and `close`. `connect()`
|
||||
supplies asynchronous equivalents and an eight-request dispatch queue. Close the
|
||||
client after use. One Job should have one active stdin consumer. This is an
|
||||
explicit JSON bridge, not a replacement for every Node fs or child_process API.
|
||||
|
||||
`filesystem-read.h` runs only on the host, requires a fixed local NTFS volume,
|
||||
pins all ancestors/targets with read sharing only, rejects reparse/multiple-link
|
||||
files, and compares root file identity and final normalized volume paths. The
|
||||
host checks policy before and after reads and again for every cached chunk.
|
||||
Reads are limited to 8 MiB or the tighter file policy; immutable snapshots are
|
||||
served in 16 KiB chunks, with at most eight snapshots/16 MiB per Job, a 60-second
|
||||
idle lifetime and a full client SHA-256 check. Directory listing is bounded to
|
||||
128 raw entries. Native readers monitor their parent and have a four-second
|
||||
deadline. No writable handle is returned to sandbox code.
|
||||
|
||||
The strict protocol caps requests at 8 KiB, replies at 24 KiB, eight concurrent
|
||||
operations and 1024 total requests per Job. Replay, forged ownership, oversized
|
||||
or incomplete frames cancel the channel/Job. Ordinary output is streamed. Final
|
||||
operation metadata is flushed before replying; contents/tokens are not persisted.
|
||||
Completed results remain queryable after a broken reply. Interrupted pending
|
||||
records are unknown, never inferred successful:
|
||||
|
||||
```powershell
|
||||
npm run test:sandbox:read-bridge
|
||||
npm run test:sandbox:read-protocol
|
||||
npm run sandbox:maintain -- broker-operation RUN_UUID REQUEST_UUID --state-root 'C:\absolute\userData\sandbox'
|
||||
```
|
||||
|
||||
Preload also exposes `app.sandboxBrokerOperation(runId, requestId)` to the owned
|
||||
top-level application page. Ownership and record IDs are checked on the host.
|
||||
The bridge grants a read scope to the whole bound Job, not to individually
|
||||
authenticated descendant PIDs. Full host write/file-descriptor shims, Python and
|
||||
Shell adapters remain pending. See the implementation and test record in
|
||||
`docs/沙盒只读文件桥接入-2026-10-18.md`.
|
||||
|
||||
To maintain a policy, edit the relevant module, add an adversarial case to
|
||||
`scripts/sandbox-regression.ts`, run the native suite and application integration,
|
||||
then rebuild. Keep protocol changes synchronized between runner, manifest,
|
||||
service and tests. Do not enable ordinary host spawn as a default-mode fallback.
|
||||
|
||||
## Current scope and limits
|
||||
|
||||
- Windows x64 is implemented. Other platforms reject default-mode commands.
|
||||
- The model command path is isolated. The OMP sidecar, manually operated terminal,
|
||||
local MCP server processes and remote connectors retain their existing trust
|
||||
and approval models; their process confinement is separate work.
|
||||
- Input excludes credentials/protected names, `.git`, `.ycode`, `.tmp`,
|
||||
`node_modules`, `.venv`, `venv`, `__pycache__` and symbolic links. Multi-link files are rejected. Limits are
|
||||
10,000 entries, 128 MiB of copied/generated file data, 8 MiB per file, 120 seconds
|
||||
by default, and 2 MiB retained per stdout/stderr stream.
|
||||
- File-count/size limits are snapshot and writeback checks, not an OS disk quota.
|
||||
Hard disk quotas and automatic audit/backup retention are pending work.
|
||||
- Cmd and Windows PowerShell work in the mapped stage. Explicitly imported
|
||||
Node/npm and Python/pip run offline with controlled optional dependency layers;
|
||||
Git supports the private filtered repository described above.
|
||||
Runtimes and package managers are not automatically copied or granted access.
|
||||
Commands must use relative scratch paths; actual original workspace absolute
|
||||
paths are not redirected. Persistent filtered drafts are opt-in as described
|
||||
above; persistent processes/home/temp, mutable dependency layers, host Git
|
||||
history/index integration and metadata writeback, remote Git, network installs,
|
||||
pnpm link layouts and comprehensive native-addon compatibility remain pending.
|
||||
- File creation/update/deletion, empty directories and directory tree deletion or
|
||||
rename are supported through versioned transactions. Deletion preflight refuses
|
||||
unknown/excluded host contents; recovery refuses later external additions.
|
||||
Directory metadata, ACLs, timestamps, arbitrary reparse behavior and binary
|
||||
compatibility with every program remain outside this first acceptance set.
|
||||
- AppContainer can read shared Windows resources and other objects permitted by
|
||||
Windows ACLs. It does not implement a universal read-path allowlist or DLP.
|
||||
A workspace credential filter is an additional application policy.
|
||||
- Registry/domain policies, configurable network egress, connector isolation,
|
||||
security-center UI and WorkBuddy equivalence remain separate acceptance tasks.
|
||||
- Read-bridge operations pin native handles. Host commit/compensation/recovery
|
||||
now use the host-only NTFS transaction channel in `filesystem-write.h`, with
|
||||
root identity, relative directory-handle operations and a v3 intent journal.
|
||||
Replacement uses a no-overwrite two-rename handoff, with a brief absent-name
|
||||
window rather than one atomic swap. Changed/unknown scratch content refuses
|
||||
automatic cleanup. Full shims/metadata and power-loss acceptance remain pending;
|
||||
see `docs/沙盒原生写回事务-2026-10-18.md`.
|
||||
- The recovery journal is intended for process interruption. Power-loss durability
|
||||
and independent security review require additional validation.
|
||||
|
||||
## Primary API references
|
||||
|
||||
- [AppContainer isolation](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-isolation)
|
||||
- [CreateAppContainerProfile](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile)
|
||||
- [Process attributes, including security capabilities and handle lists](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)
|
||||
- [Job objects and process trees](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)
|
||||
@@ -1,174 +0,0 @@
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <cwctype>
|
||||
|
||||
// This host-only reader never grants a workspace handle to the sandbox.
|
||||
namespace workspace_read {
|
||||
struct Lifetime {
|
||||
Handle parent{parentProcess()}, done{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
|
||||
std::thread monitor;
|
||||
explicit Lifetime(DWORD timeout = 4000) {
|
||||
require(done.value != nullptr, "Create reader lifecycle event");
|
||||
monitor = std::thread([this, timeout]() {
|
||||
HANDLE events[] = {parent.value, done.value};
|
||||
const DWORD state = WaitForMultipleObjects(2, events, FALSE, timeout);
|
||||
if (state != WAIT_OBJECT_0 + 1) TerminateProcess(GetCurrentProcess(), 125);
|
||||
});
|
||||
}
|
||||
~Lifetime() { SetEvent(done.value); monitor.join(); }
|
||||
};
|
||||
static bool component(const std::wstring& name) {
|
||||
if (name.empty() || name == L"." || name == L".." || name.back() == L'.' || name.back() == L' ') return false;
|
||||
for (wchar_t ch : name) if (ch < 32 || std::wstring(L"<>:\"/\\|?*").find(ch) != std::wstring::npos) return false;
|
||||
auto stem = name.substr(0, name.find(L'.'));
|
||||
std::transform(stem.begin(), stem.end(), stem.begin(), [](wchar_t ch) { return static_cast<wchar_t>(towupper(ch)); });
|
||||
if (stem == L"CON" || stem == L"PRN" || stem == L"AUX" || stem == L"NUL") return false;
|
||||
return !(stem.size() == 4 && (stem.substr(0, 3) == L"COM" || stem.substr(0, 3) == L"LPT") && stem[3] >= L'1' && stem[3] <= L'9');
|
||||
}
|
||||
static std::vector<std::wstring> parts(std::wstring value) {
|
||||
std::replace(value.begin(), value.end(), L'/', L'\\');
|
||||
std::vector<std::wstring> result;
|
||||
size_t start = 0;
|
||||
for (;;) {
|
||||
const auto end = value.find(L'\\', start);
|
||||
const auto name = value.substr(start, end == std::wstring::npos ? end : end - start);
|
||||
if (!component(name)) throw std::runtime_error("Invalid workspace path component");
|
||||
result.push_back(name);
|
||||
if (end == std::wstring::npos) return result;
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
static std::wstring finalPath(HANDLE handle) {
|
||||
std::vector<wchar_t> buffer(32768);
|
||||
const DWORD count = GetFinalPathNameByHandleW(handle, buffer.data(), static_cast<DWORD>(buffer.size()), FILE_NAME_NORMALIZED | VOLUME_NAME_GUID);
|
||||
require(count > 0 && count < buffer.size(), "Resolve workspace handle");
|
||||
std::wstring path(buffer.data(), count);
|
||||
while (path.size() > 1 && path.back() == L'\\') path.pop_back();
|
||||
return path;
|
||||
}
|
||||
static BY_HANDLE_FILE_INFORMATION information(HANDLE handle) {
|
||||
BY_HANDLE_FILE_INFORMATION result = {};
|
||||
require(GetFileInformationByHandle(handle, &result) != FALSE, "Inspect workspace handle");
|
||||
if (result.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE)) throw std::runtime_error("Workspace reparse point or device denied");
|
||||
if (!(result.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && result.nNumberOfLinks != 1) throw std::runtime_error("Workspace hard link denied");
|
||||
return result;
|
||||
}
|
||||
static std::string identity(HANDLE handle) {
|
||||
FILE_ID_INFO id = {};
|
||||
require(GetFileInformationByHandleEx(handle, FileIdInfo, &id, sizeof(id)) != FALSE, "Identify workspace handle");
|
||||
std::ostringstream text;
|
||||
text << std::hex << std::setfill('0') << std::setw(16) << id.VolumeSerialNumber << ':';
|
||||
for (BYTE byte : id.FileId.Identifier) text << std::setw(2) << static_cast<unsigned int>(byte);
|
||||
return text.str();
|
||||
}
|
||||
struct Target {
|
||||
std::vector<std::unique_ptr<Handle>> held;
|
||||
fs::path path;
|
||||
std::wstring rootFinal;
|
||||
std::string rootIdentity;
|
||||
HANDLE open(bool directory) {
|
||||
// Read sharing only pins names against rename/delete and excludes writable handles,
|
||||
// including handles used to retarget a reparse point. All ancestors stay open.
|
||||
auto handle = std::make_unique<Handle>(CreateFileW(path.c_str(), directory ? FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES : GENERIC_READ,
|
||||
FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
require(handle->value != INVALID_HANDLE_VALUE, "Open pinned workspace target");
|
||||
const auto info = information(handle->value);
|
||||
if (directory && !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) throw std::runtime_error("Workspace ancestor is not a directory");
|
||||
const auto result = handle->value;
|
||||
held.push_back(std::move(handle));
|
||||
return result;
|
||||
}
|
||||
explicit Target(const std::wstring& root) {
|
||||
if (root.size() < 3 || root.size() > 1024 || !iswalpha(root[0]) || root[1] != L':' || root[2] != L'\\') throw std::runtime_error("Workspace requires a local drive path");
|
||||
path = root.substr(0, 3);
|
||||
if (GetDriveTypeW(path.c_str()) != DRIVE_FIXED) throw std::runtime_error("Workspace requires a fixed local volume");
|
||||
HANDLE last = open(true);
|
||||
wchar_t filesystem[32] = {};
|
||||
require(GetVolumeInformationByHandleW(last, nullptr, 0, nullptr, nullptr, nullptr, filesystem, 32) != FALSE, "Inspect workspace volume");
|
||||
if (_wcsicmp(filesystem, L"NTFS") != 0) throw std::runtime_error("Workspace reader currently requires NTFS");
|
||||
if (root.size() > 3) for (const auto& name : parts(root.substr(3))) { path /= name; last = open(true); }
|
||||
rootFinal = finalPath(last);
|
||||
rootIdentity = identity(last);
|
||||
}
|
||||
HANDLE resolve(const std::wstring& relative) {
|
||||
if (relative == L".") return held.back()->value;
|
||||
const auto names = parts(relative);
|
||||
HANDLE last = nullptr;
|
||||
for (size_t index = 0; index < names.size(); ++index) { path /= names[index]; last = open(index + 1 < names.size()); }
|
||||
return last;
|
||||
}
|
||||
std::wstring relative(HANDLE handle) const {
|
||||
const auto actual = finalPath(handle);
|
||||
if (_wcsicmp(actual.c_str(), rootFinal.c_str()) == 0) return L".";
|
||||
if (actual.size() <= rootFinal.size() || _wcsnicmp(actual.c_str(), rootFinal.c_str(), rootFinal.size()) != 0 || actual[rootFinal.size()] != L'\\') throw std::runtime_error("Workspace handle escaped root");
|
||||
return actual.substr(rootFinal.size() + 1);
|
||||
}
|
||||
};
|
||||
static std::string base64(const std::vector<BYTE>& bytes) {
|
||||
static const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string result; result.reserve((bytes.size() + 2) / 3 * 4);
|
||||
for (size_t index = 0; index < bytes.size(); index += 3) {
|
||||
const unsigned int value = (static_cast<unsigned int>(bytes[index]) << 16) |
|
||||
(index + 1 < bytes.size() ? static_cast<unsigned int>(bytes[index + 1]) << 8 : 0) | (index + 2 < bytes.size() ? bytes[index + 2] : 0);
|
||||
result += alphabet[value >> 18]; result += alphabet[(value >> 12) & 63];
|
||||
result += index + 1 < bytes.size() ? alphabet[(value >> 6) & 63] : '=';
|
||||
result += index + 2 < bytes.size() ? alphabet[value & 63] : '=';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static int run(const std::wstring& operation, const std::wstring& root, const std::wstring& relative, const std::string& expected, DWORD limit) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace reader is host-only");
|
||||
Lifetime lifetime;
|
||||
if (operation != L"identity" && operation != L"read" && operation != L"stat" && operation != L"list") throw std::runtime_error("Invalid workspace reader operation");
|
||||
if (limit == 0 || limit > 8 * 1024 * 1024) throw std::runtime_error("Invalid workspace reader limit");
|
||||
Target target(root);
|
||||
if (operation != L"identity" && target.rootIdentity != expected) throw std::runtime_error("Workspace root identity changed");
|
||||
const HANDLE handle = target.resolve(relative);
|
||||
const auto before = information(handle);
|
||||
const bool directory = (before.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||
const auto size = (static_cast<ULONGLONG>(before.nFileSizeHigh) << 32) | before.nFileSizeLow;
|
||||
std::string result = "{\"protocol\":1,\"rootIdentity\":\"" + target.rootIdentity + "\",\"identity\":\"" + identity(handle) +
|
||||
"\",\"path\":\"" + jsonText(target.relative(handle)) + "\",\"kind\":\"" + (directory ? "directory" : "file") + "\",\"size\":" + std::to_string(size);
|
||||
if (operation == L"read") {
|
||||
if (directory) throw std::runtime_error("Workspace target is a directory");
|
||||
if (size > limit) throw std::runtime_error("Workspace file exceeds read limit");
|
||||
std::vector<BYTE> bytes(static_cast<size_t>(size)); DWORD read = 0;
|
||||
if (!bytes.empty()) require(ReadFile(handle, bytes.data(), static_cast<DWORD>(bytes.size()), &read, nullptr) && read == bytes.size(), "Read pinned workspace file");
|
||||
const auto after = information(handle);
|
||||
if (after.nNumberOfLinks != 1 || after.nFileSizeLow != before.nFileSizeLow || after.nFileSizeHigh != before.nFileSizeHigh || CompareFileTime(&before.ftLastWriteTime, &after.ftLastWriteTime) != 0) throw std::runtime_error("Workspace file changed while reading");
|
||||
result += ",\"data\":\"" + base64(bytes) + "\"";
|
||||
} else if (operation == L"list") {
|
||||
if (!directory) throw std::runtime_error("Workspace target is not a directory");
|
||||
result += ",\"entries\":[";
|
||||
std::vector<BYTE> buffer(65536); bool first = true; DWORD count = 0;
|
||||
for (;;) {
|
||||
if (!GetFileInformationByHandleEx(handle, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
|
||||
if (GetLastError() == ERROR_NO_MORE_FILES) break;
|
||||
require(false, "Enumerate pinned workspace directory");
|
||||
}
|
||||
size_t offset = 0;
|
||||
for (;;) {
|
||||
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
|
||||
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid directory record");
|
||||
const std::wstring name(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
|
||||
if (name != L"." && name != L"..") {
|
||||
if (++count > limit) throw std::runtime_error("Workspace directory exceeds entry limit");
|
||||
if (!first) result += ',';
|
||||
first = false;
|
||||
result += "{\"name\":\"" + jsonText(name) + "\",\"kind\":\"" + ((entry->FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) ? "link" : (entry->FileAttributes & FILE_ATTRIBUTE_DIRECTORY) ? "directory" : "file") + "\"}";
|
||||
}
|
||||
if (entry->NextEntryOffset == 0) break;
|
||||
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid next directory record");
|
||||
offset += entry->NextEntryOffset;
|
||||
}
|
||||
}
|
||||
result += ']';
|
||||
}
|
||||
result += "}\n";
|
||||
DWORD written = 0;
|
||||
require(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), result.data(), static_cast<DWORD>(result.size()), &written, nullptr) && written == result.size(), "Write workspace reader result");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
#pragma once
|
||||
#include <bcrypt.h>
|
||||
#include <map>
|
||||
#include <winternl.h>
|
||||
|
||||
// Host-owned transaction channel. No workspace handles cross into an AppContainer.
|
||||
namespace workspace_write {
|
||||
static size_t fileLimit = 8 * 1024 * 1024;
|
||||
using CreateFileFn = NTSTATUS(NTAPI*)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, PIO_STATUS_BLOCK, PLARGE_INTEGER, ULONG, ULONG, ULONG, ULONG, PVOID, ULONG);
|
||||
using SetFileFn = NTSTATUS(NTAPI*)(HANDLE, PIO_STATUS_BLOCK, PVOID, ULONG, FILE_INFORMATION_CLASS);
|
||||
using ErrorFn = ULONG(NTAPI*)(NTSTATUS);
|
||||
template<class Function> static Function nativeFunction(const char* name) {
|
||||
const auto address = GetProcAddress(GetModuleHandleW(L"ntdll.dll"), name);
|
||||
require(address != nullptr, "Resolve native transaction API");
|
||||
Function result = nullptr;
|
||||
static_assert(sizeof(result) == sizeof(address)); memcpy(&result, &address, sizeof(result));
|
||||
return result;
|
||||
}
|
||||
static bool nativeOk(NTSTATUS status) {
|
||||
if (status >= 0) return true;
|
||||
SetLastError(nativeFunction<ErrorFn>("RtlNtStatusToDosError")(status));
|
||||
return false;
|
||||
}
|
||||
static HANDLE openRelative(HANDLE parent, const std::wstring& name, ACCESS_MASK access, bool directory, bool create = false) {
|
||||
UNICODE_STRING text = {}; text.Buffer = const_cast<PWSTR>(name.data());
|
||||
text.Length = static_cast<USHORT>(name.size() * sizeof(wchar_t)); text.MaximumLength = text.Length;
|
||||
OBJECT_ATTRIBUTES attributes = {}; attributes.Length = sizeof(attributes); attributes.RootDirectory = parent;
|
||||
attributes.ObjectName = &text; attributes.Attributes = 0x1040; // OBJ_CASE_INSENSITIVE | OBJ_DONT_REPARSE
|
||||
IO_STATUS_BLOCK status = {}; HANDLE handle = INVALID_HANDLE_VALUE;
|
||||
const ULONG options = 0x00200000 | 0x20 | (directory ? 1 : 0); // OPEN_REPARSE_POINT, synchronous, optional directory
|
||||
const auto result = nativeFunction<CreateFileFn>("NtCreateFile")(&handle, access | SYNCHRONIZE, &attributes, &status, nullptr,
|
||||
FILE_ATTRIBUTE_NORMAL, directory ? FILE_SHARE_READ | FILE_SHARE_WRITE : FILE_SHARE_READ, create ? 2 : 1, options, nullptr, 0);
|
||||
return nativeOk(result) ? handle : INVALID_HANDLE_VALUE;
|
||||
}
|
||||
struct Root {
|
||||
std::vector<std::unique_ptr<Handle>> held;
|
||||
fs::path path;
|
||||
HANDLE handle = nullptr;
|
||||
std::wstring rootFinal;
|
||||
std::string rootIdentity;
|
||||
explicit Root(const std::wstring& input) {
|
||||
if (input.size() < 3 || input.size() > 1024 || !iswalpha(input[0]) || input[1] != L':' || input[2] != L'\\') throw std::runtime_error("Workspace requires a local drive path");
|
||||
path = input.substr(0, 3);
|
||||
if (GetDriveTypeW(path.c_str()) != DRIVE_FIXED) throw std::runtime_error("Workspace requires a fixed local volume");
|
||||
auto first = std::make_unique<Handle>(CreateFileW(path.c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
require(first->value != INVALID_HANDLE_VALUE, "Pin transaction volume");
|
||||
wchar_t filesystem[32] = {};
|
||||
require(GetVolumeInformationByHandleW(first->value, nullptr, 0, nullptr, nullptr, nullptr, filesystem, 32) != FALSE, "Inspect transaction volume");
|
||||
if (_wcsicmp(filesystem, L"NTFS") != 0) throw std::runtime_error("Workspace transaction requires NTFS");
|
||||
handle = first->value; held.push_back(std::move(first));
|
||||
if (input.size() > 3) for (const auto& name : workspace_read::parts(input.substr(3))) {
|
||||
auto next = std::make_unique<Handle>(openRelative(handle, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
|
||||
require(next->value != INVALID_HANDLE_VALUE, "Pin transaction root ancestor");
|
||||
workspace_read::information(next->value);
|
||||
handle = next->value; held.push_back(std::move(next)); path /= name;
|
||||
}
|
||||
rootFinal = workspace_read::finalPath(handle); rootIdentity = workspace_read::identity(handle);
|
||||
}
|
||||
std::wstring relative(HANDLE target) const {
|
||||
const auto actual = workspace_read::finalPath(target);
|
||||
if (actual.size() <= rootFinal.size() || _wcsnicmp(actual.c_str(), rootFinal.c_str(), rootFinal.size()) != 0 || actual[rootFinal.size()] != L'\\') throw std::runtime_error("Transaction handle escaped root");
|
||||
return actual.substr(rootFinal.size() + 1);
|
||||
}
|
||||
};
|
||||
static std::string hex(const BYTE* bytes, size_t size) {
|
||||
static const char* digits = "0123456789abcdef";
|
||||
std::string result; result.reserve(size * 2);
|
||||
for (size_t index = 0; index < size; ++index) { result += digits[bytes[index] >> 4]; result += digits[bytes[index] & 15]; }
|
||||
return result;
|
||||
}
|
||||
static std::vector<BYTE> unhex(const std::string& text, size_t limit) {
|
||||
if (text.size() % 2 || text.size() > limit * 2) throw std::runtime_error("Invalid transaction field length");
|
||||
std::vector<BYTE> result; result.reserve(text.size() / 2);
|
||||
auto digit = [](char ch) -> BYTE {
|
||||
if (ch >= '0' && ch <= '9') return static_cast<BYTE>(ch - '0');
|
||||
if (ch >= 'a' && ch <= 'f') return static_cast<BYTE>(ch - 'a' + 10);
|
||||
throw std::runtime_error("Invalid transaction hex field");
|
||||
};
|
||||
for (size_t index = 0; index < text.size(); index += 2) result.push_back(static_cast<BYTE>((digit(text[index]) << 4) | digit(text[index + 1])));
|
||||
return result;
|
||||
}
|
||||
static std::wstring pathField(const std::string& text) {
|
||||
const auto bytes = unhex(text, 4096);
|
||||
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), nullptr, 0);
|
||||
if (size <= 0 || size > 1024) throw std::runtime_error("Invalid transaction path encoding");
|
||||
std::wstring result(size, 0);
|
||||
require(MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), result.data(), size) == size, "Decode transaction path");
|
||||
return result;
|
||||
}
|
||||
static std::wstring key(std::wstring value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) { return static_cast<wchar_t>(towlower(ch)); });
|
||||
return value;
|
||||
}
|
||||
static std::string hash(const std::vector<BYTE>& bytes) {
|
||||
BCRYPT_ALG_HANDLE algorithm = nullptr;
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) throw std::runtime_error("Open transaction SHA256");
|
||||
BYTE result[32] = {};
|
||||
const auto status = BCryptHash(algorithm, nullptr, 0, const_cast<BYTE*>(bytes.data()), static_cast<ULONG>(bytes.size()), result, sizeof(result));
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
if (status < 0) throw std::runtime_error("Hash transaction contents");
|
||||
return hex(result, sizeof(result));
|
||||
}
|
||||
static std::vector<BYTE> contents(HANDLE handle) {
|
||||
const auto info = workspace_read::information(handle);
|
||||
const auto size = (static_cast<ULONGLONG>(info.nFileSizeHigh) << 32) | info.nFileSizeLow;
|
||||
if ((info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || size > fileLimit) throw std::runtime_error("Transaction file type or size denied");
|
||||
std::vector<BYTE> bytes(static_cast<size_t>(size)); DWORD count = 0;
|
||||
if (!bytes.empty()) require(ReadFile(handle, bytes.data(), static_cast<DWORD>(bytes.size()), &count, nullptr) && count == bytes.size(), "Read pinned transaction file");
|
||||
return bytes;
|
||||
}
|
||||
static void reply(const std::string& text) {
|
||||
const auto line = text + '\n'; DWORD count = 0;
|
||||
require(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), line.data(), static_cast<DWORD>(line.size()), &count, nullptr) && count == line.size(), "Write transaction response");
|
||||
}
|
||||
struct Session {
|
||||
struct MissingParent : std::runtime_error { MissingParent() : std::runtime_error("Transaction parent missing") {} };
|
||||
Root root;
|
||||
std::map<std::wstring, std::unique_ptr<Handle>> directories;
|
||||
std::map<std::wstring, std::string> known;
|
||||
explicit Session(const std::wstring& path, const std::string& expected) : root(path) {
|
||||
if (expected != "-" && expected != root.rootIdentity) throw std::runtime_error("Workspace root identity changed");
|
||||
}
|
||||
void validate(HANDLE handle, const std::wstring& relative, bool directory) {
|
||||
for (const auto& ancestor : root.held) workspace_read::information(ancestor->value);
|
||||
const auto info = workspace_read::information(handle);
|
||||
if (directory != ((info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)) throw std::runtime_error("Transaction target type changed");
|
||||
if (_wcsicmp(root.relative(handle).c_str(), relative.c_str()) != 0) throw std::runtime_error("Transaction final name changed");
|
||||
}
|
||||
HANDLE parent(const std::vector<std::wstring>& names) {
|
||||
HANDLE parentHandle = root.handle; std::wstring relative;
|
||||
workspace_read::information(parentHandle);
|
||||
for (size_t index = 0; index + 1 < names.size(); ++index) {
|
||||
if (!relative.empty()) relative += L'\\'; relative += names[index];
|
||||
const auto name = key(relative);
|
||||
if (directories.count(name)) { parentHandle = directories[name]->value; workspace_read::information(parentHandle); continue; }
|
||||
if (directories.size() >= 10000) throw std::runtime_error("Transaction directory pin limit");
|
||||
auto handle = std::make_unique<Handle>(openRelative(parentHandle, names[index], FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
|
||||
if (handle->value == INVALID_HANDLE_VALUE && (GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND)) throw MissingParent();
|
||||
require(handle->value != INVALID_HANDLE_VALUE, "Pin transaction ancestor");
|
||||
validate(handle->value, relative, true);
|
||||
parentHandle = handle->value;
|
||||
directories.emplace(name, std::move(handle));
|
||||
}
|
||||
return parentHandle;
|
||||
}
|
||||
void unpin(const std::wstring& relative) {
|
||||
const auto prefix = key(relative);
|
||||
for (auto entry = directories.begin(); entry != directories.end();) {
|
||||
if (entry->first == prefix || entry->first.compare(0, prefix.size() + 1, prefix + L'\\') == 0) entry = directories.erase(entry);
|
||||
else ++entry;
|
||||
}
|
||||
}
|
||||
std::string execute(const std::vector<std::string>& fields) {
|
||||
if (fields.size() != 5) throw std::runtime_error("Invalid transaction frame");
|
||||
const auto& operation = fields[0]; const auto relative = pathField(fields[1]);
|
||||
const auto names = workspace_read::parts(relative);
|
||||
const auto name = key(relative);
|
||||
HANDLE directory = nullptr;
|
||||
try { directory = parent(names); }
|
||||
catch (const MissingParent&) {
|
||||
if (operation == "cleanup") return "{\"ok\":true}";
|
||||
if (operation == "read" || operation == "stat") return "{\"ok\":true,\"kind\":\"missing\"}";
|
||||
throw;
|
||||
}
|
||||
const auto absolute = names.back();
|
||||
const auto& nonce = fields[4];
|
||||
if (operation == "write" || operation == "cleanup") {
|
||||
if (nonce.size() != 32 || nonce.find_first_not_of("0123456789abcdef") != std::string::npos) throw std::runtime_error("Invalid transaction mutation identity");
|
||||
}
|
||||
const auto temporaryName = L".ycode-sandbox-" + std::wstring(nonce.begin(), nonce.end());
|
||||
const auto temporary = temporaryName + L"-new.tmp";
|
||||
const auto previous = temporaryName + L"-old.tmp";
|
||||
if (operation == "cleanup") {
|
||||
const auto separator = fields[2].find(':');
|
||||
if (separator == std::string::npos) throw std::runtime_error("Invalid transaction cleanup hashes");
|
||||
const std::pair<fs::path, std::string> candidates[] = {{previous, fields[2].substr(0, separator)}, {temporary, fields[2].substr(separator + 1)}};
|
||||
for (const auto& candidate : candidates) {
|
||||
Handle stale(openRelative(directory, candidate.first.wstring(), GENERIC_READ | DELETE, false));
|
||||
if (stale.value == INVALID_HANDLE_VALUE && (GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND)) continue;
|
||||
require(stale.value != INVALID_HANDLE_VALUE, "Open transaction recovery scratch");
|
||||
validate(stale.value, (fs::path(relative).parent_path() / candidate.first.filename()).wstring(), false);
|
||||
if (hash(contents(stale.value)) != candidate.second) throw std::runtime_error("Transaction scratch changed externally");
|
||||
FILE_DISPOSITION_INFO disposition = {TRUE};
|
||||
require(SetFileInformationByHandle(stale.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Remove transaction recovery scratch");
|
||||
}
|
||||
return "{\"ok\":true}";
|
||||
}
|
||||
if (operation == "mkdir") {
|
||||
Handle created(openRelative(directory, absolute, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true, true));
|
||||
require(created.value != INVALID_HANDLE_VALUE, "Create pinned transaction directory");
|
||||
validate(created.value, relative, true);
|
||||
const auto identity = workspace_read::identity(created.value);
|
||||
directories[name] = std::make_unique<Handle>(created.value); created.value = nullptr;
|
||||
known.erase(name);
|
||||
return "{\"ok\":true,\"identity\":\"" + identity + "\"}";
|
||||
}
|
||||
if (operation == "rmdir") unpin(relative);
|
||||
const bool mutation = operation == "write" || operation == "unlink" || operation == "rmdir";
|
||||
Handle target(openRelative(directory, absolute, GENERIC_READ | (mutation ? DELETE : 0), operation == "rmdir"));
|
||||
const DWORD error = target.value == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS;
|
||||
const bool missing = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
|
||||
if (target.value == INVALID_HANDLE_VALUE && !missing) { SetLastError(error); require(false, "Open pinned transaction target"); }
|
||||
if (operation == "read" || operation == "stat") {
|
||||
if (missing) { known.erase(name); return "{\"ok\":true,\"kind\":\"missing\"}"; }
|
||||
const bool isDirectory = (workspace_read::information(target.value).dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||
validate(target.value, relative, isDirectory);
|
||||
const auto identity = workspace_read::identity(target.value); known[name] = identity;
|
||||
if (isDirectory && !directories.count(name)) {
|
||||
if (directories.size() >= 10000) throw std::runtime_error("Transaction directory pin limit");
|
||||
auto pinned = std::make_unique<Handle>(openRelative(directory, absolute, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
|
||||
require(pinned->value != INVALID_HANDLE_VALUE, "Pin inspected transaction directory");
|
||||
validate(pinned->value, relative, true);
|
||||
if (workspace_read::identity(pinned->value) != identity) throw std::runtime_error("Inspected transaction directory identity changed");
|
||||
directories[name] = std::move(pinned);
|
||||
}
|
||||
if (operation == "read" && isDirectory) throw std::runtime_error("Transaction target is a directory");
|
||||
std::string result = "{\"ok\":true,\"kind\":\"" + std::string(isDirectory ? "directory" : "file") + "\",\"identity\":\"" + identity + "\"";
|
||||
if (operation == "read") result += ",\"data\":\"" + workspace_read::base64(contents(target.value)) + "\"";
|
||||
return result + '}';
|
||||
}
|
||||
if (operation == "list") {
|
||||
if (missing) throw std::runtime_error("Transaction directory missing");
|
||||
validate(target.value, relative, true);
|
||||
std::string result = "{\"ok\":true,\"entries\":["; bool first = true; size_t count = 0;
|
||||
std::vector<BYTE> buffer(65536);
|
||||
for (;;) {
|
||||
if (!GetFileInformationByHandleEx(target.value, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
|
||||
if (GetLastError() == ERROR_NO_MORE_FILES) break;
|
||||
require(false, "Enumerate transaction directory");
|
||||
}
|
||||
size_t offset = 0;
|
||||
for (;;) {
|
||||
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
|
||||
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid transaction directory record");
|
||||
const std::wstring child(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
|
||||
if (child != L"." && child != L"..") {
|
||||
if (++count > 10000 || !workspace_read::component(child)) throw std::runtime_error("Transaction directory entry denied");
|
||||
if (!first) result += ','; first = false;
|
||||
result += "\"" + jsonText(child) + "\"";
|
||||
}
|
||||
if (entry->NextEntryOffset == 0) break;
|
||||
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid transaction directory offset");
|
||||
offset += entry->NextEntryOffset;
|
||||
}
|
||||
}
|
||||
return result + "]}";
|
||||
}
|
||||
if (!mutation) throw std::runtime_error("Unknown transaction operation");
|
||||
if (!missing) {
|
||||
validate(target.value, relative, operation == "rmdir");
|
||||
if (operation != "rmdir" && (workspace_read::information(target.value).dwFileAttributes & FILE_ATTRIBUTE_READONLY)) throw std::runtime_error("Transaction read-only file denied");
|
||||
const auto identity = workspace_read::identity(target.value);
|
||||
if (known.count(name) && known[name] != identity) throw std::runtime_error("Transaction target identity changed");
|
||||
if (operation == "rmdir") {
|
||||
if (fields[2] != identity) throw std::runtime_error("Transaction directory identity changed");
|
||||
} else if (fields[2] != hash(contents(target.value))) throw std::runtime_error("Transaction contents changed");
|
||||
} else if (operation != "write" || fields[2] != "-") throw std::runtime_error("Transaction target disappeared");
|
||||
if (operation == "unlink" || operation == "rmdir") {
|
||||
FILE_DISPOSITION_INFO disposition = {TRUE};
|
||||
require(SetFileInformationByHandle(target.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Delete pinned transaction target");
|
||||
known.erase(name);
|
||||
return "{\"ok\":true}";
|
||||
}
|
||||
const auto bytes = unhex(fields[3], fileLimit);
|
||||
Handle output(openRelative(directory, temporary, GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES, false, true));
|
||||
require(output.value != INVALID_HANDLE_VALUE, "Create transaction replacement");
|
||||
bool published = false, moved = false;
|
||||
auto renameHandle = [directory](HANDLE handle, const fs::path& destination) {
|
||||
const auto name = destination.wstring();
|
||||
std::vector<BYTE> storage(offsetof(FILE_RENAME_INFO, FileName) + name.size() * sizeof(wchar_t));
|
||||
auto rename = reinterpret_cast<FILE_RENAME_INFO*>(storage.data());
|
||||
rename->ReplaceIfExists = FALSE;
|
||||
rename->RootDirectory = directory; rename->FileNameLength = static_cast<DWORD>(name.size() * sizeof(wchar_t));
|
||||
memcpy(rename->FileName, name.data(), rename->FileNameLength);
|
||||
IO_STATUS_BLOCK status = {};
|
||||
return nativeOk(nativeFunction<SetFileFn>("NtSetInformationFile")(handle, &status, rename, static_cast<ULONG>(storage.size()), static_cast<FILE_INFORMATION_CLASS>(10)));
|
||||
};
|
||||
try {
|
||||
DWORD count = 0;
|
||||
if (!bytes.empty()) require(WriteFile(output.value, bytes.data(), static_cast<DWORD>(bytes.size()), &count, nullptr) && count == bytes.size(), "Write transaction replacement");
|
||||
require(FlushFileBuffers(output.value) != FALSE, "Flush transaction replacement");
|
||||
validate(output.value, (fs::path(relative).parent_path() / fs::path(temporary).filename()).wstring(), false);
|
||||
const auto replacementIdentity = workspace_read::identity(output.value);
|
||||
// A no-overwrite handoff keeps the original handle exclusively pinned.
|
||||
// A competing basename creation fails publication instead of losing data.
|
||||
if (!missing) { require(renameHandle(target.value, previous), "Stage pinned transaction original"); moved = true; }
|
||||
require(renameHandle(output.value, absolute), "Publish pinned transaction replacement");
|
||||
published = true;
|
||||
known[name] = replacementIdentity;
|
||||
if (moved) {
|
||||
FILE_DISPOSITION_INFO disposition = {TRUE};
|
||||
require(SetFileInformationByHandle(target.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Remove pinned transaction original");
|
||||
}
|
||||
} catch (...) {
|
||||
FILE_DISPOSITION_INFO disposition = {TRUE};
|
||||
if (!published) SetFileInformationByHandle(output.value, FileDispositionInfo, &disposition, sizeof(disposition));
|
||||
if (moved && !published && !renameHandle(target.value, absolute)) throw std::runtime_error("Transaction handoff needs recovery; competing target preserved");
|
||||
throw;
|
||||
}
|
||||
return "{\"ok\":true}";
|
||||
}
|
||||
};
|
||||
static int run(const std::wstring& root, const std::string& expected, bool gitMetadata = false) {
|
||||
if (gitMetadata) fileLimit = 128 * 1024 * 1024;
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace transaction is host-only");
|
||||
workspace_read::Lifetime lifetime(600000);
|
||||
Session session(root, expected);
|
||||
reply("{\"protocol\":1,\"rootIdentity\":\"" + session.root.rootIdentity + "\"}");
|
||||
std::string line; size_t requests = 0; char buffer[16384]; DWORD count = 0;
|
||||
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &count, nullptr) && count) {
|
||||
for (DWORD index = 0; index < count; ++index) {
|
||||
if (buffer[index] != '\n') {
|
||||
if (line.size() >= fileLimit * 2 + 16384) throw std::runtime_error("Transaction frame limit");
|
||||
line += buffer[index]; continue;
|
||||
}
|
||||
if (++requests > 100000) throw std::runtime_error("Transaction request limit");
|
||||
std::vector<std::string> fields; size_t start = 0;
|
||||
for (;;) { const auto end = line.find('\t', start); fields.push_back(line.substr(start, end == std::string::npos ? end : end - start)); if (end == std::string::npos) break; start = end + 1; }
|
||||
try { reply(session.execute(fields)); }
|
||||
catch (const std::exception& error) {
|
||||
std::string message;
|
||||
for (char ch : std::string(error.what())) { if (ch == '\\' || ch == '"') message += '\\'; message += ch; }
|
||||
reply("{\"ok\":false,\"error\":\"" + message + "\"}");
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
if (!line.empty()) throw std::runtime_error("Truncated transaction frame");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
#pragma once
|
||||
#include <wincrypt.h>
|
||||
#pragma comment(lib, "crypt32.lib")
|
||||
|
||||
namespace git_control {
|
||||
static int ssh(int argc, wchar_t** argv) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git SSH control is host-only");
|
||||
BOOL inJob = FALSE; require(IsProcessInJob(GetCurrentProcess(), nullptr, &inJob) != FALSE && inJob, "Git SSH requires its control Job");
|
||||
wchar_t module[32768] = {};
|
||||
require(GetModuleFileNameW(nullptr, module, 32768) > 0, "Locate Git SSH control");
|
||||
const auto tools = fs::path(module).parent_path(), root = tools.parent_path().parent_path(), executable = tools / L"ssh.exe";
|
||||
for (const auto& path : {executable, root / L"ssh-config", root / L"ssh-key", root / L"known-hosts"}) nonReparse(path);
|
||||
std::vector<std::wstring> args = {L"-F", (root / L"ssh-config").wstring(), L"-o", L"BatchMode=yes", L"-o", L"IdentitiesOnly=yes", L"-o", L"IdentityAgent=none",
|
||||
L"-o", L"ProxyCommand=none", L"-o", L"ProxyJump=none", L"-o", L"CanonicalizeHostname=no", L"-o", L"ClearAllForwardings=yes", L"-o", L"PermitLocalCommand=no",
|
||||
L"-o", L"StrictHostKeyChecking=yes", L"-o", L"UpdateHostKeys=no", L"-o", L"UserKnownHostsFile=" + (root / L"known-hosts").wstring(),
|
||||
L"-o", L"GlobalKnownHostsFile=" + (root / L"ssh-config").wstring(), L"-i", (root / L"ssh-key").wstring()};
|
||||
for (int index = 1; index < argc; ++index) args.push_back(argv[index]);
|
||||
auto command = quote(executable.wstring()); for (const auto& arg : args) command += L" " + quote(arg);
|
||||
if (command.size() > 32000) throw std::runtime_error("Git SSH argv exceeds limit");
|
||||
Handle input, output, error;
|
||||
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_INPUT_HANDLE), GetCurrentProcess(), &input.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stdin");
|
||||
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_OUTPUT_HANDLE), GetCurrentProcess(), &output.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stdout");
|
||||
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_ERROR_HANDLE), GetCurrentProcess(), &error.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stderr");
|
||||
AttributeList attributes; HANDLE handles[] = {input.value, output.value, error.value};
|
||||
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Git SSH handle allowlist");
|
||||
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup); startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.StartupInfo.hStdInput = input.value; startup.StartupInfo.hStdOutput = output.value; startup.StartupInfo.hStdError = error.value; startup.lpAttributeList = attributes.list;
|
||||
PROCESS_INFORMATION process = {};
|
||||
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE, EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, root.c_str(), &startup.StartupInfo, &process) != FALSE, "Create Git SSH process");
|
||||
Handle child(process.hProcess), thread(process.hThread); WaitForSingleObject(child.value, INFINITE);
|
||||
DWORD exit = 125; require(GetExitCodeProcess(child.value, &exit) != FALSE, "Read Git SSH exit"); return static_cast<int>(exit);
|
||||
}
|
||||
static int credential(bool protect) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git credentials are host-only");
|
||||
std::string input; char buffer[4096]; DWORD count = 0;
|
||||
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &count, nullptr) && count) {
|
||||
input.append(buffer, count);
|
||||
if (input.size() > 131072) throw std::runtime_error("Git credential exceeds limit");
|
||||
}
|
||||
auto bytes = workspace_write::unhex(input, 65536);
|
||||
DATA_BLOB source = {static_cast<DWORD>(bytes.size()), bytes.data()}, output = {};
|
||||
const BOOL result = protect ? CryptProtectData(&source, L"YCode Git credential", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)
|
||||
: CryptUnprotectData(&source, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output);
|
||||
require(result != FALSE, "Protect/unprotect Git credential with user DPAPI");
|
||||
const auto encoded = workspace_write::hex(output.pbData, output.cbData);
|
||||
SecureZeroMemory(output.pbData, output.cbData); LocalFree(output.pbData);
|
||||
SecureZeroMemory(bytes.data(), bytes.size());
|
||||
workspace_write::reply("{\"data\":\"" + encoded + "\"}");
|
||||
return 0;
|
||||
}
|
||||
static std::wstring text(const std::string& encoded) {
|
||||
const auto bytes = workspace_write::unhex(encoded, 1024 * 1024);
|
||||
if (bytes.empty()) return L"";
|
||||
const int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), nullptr, 0);
|
||||
if (count <= 0) throw std::runtime_error("Invalid Git control UTF-8");
|
||||
std::wstring value(count, 0);
|
||||
require(MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), value.data(), count) == count, "Decode Git control text");
|
||||
if (value.find(L'\0') != std::wstring::npos) throw std::runtime_error("NUL in Git control text");
|
||||
return value;
|
||||
}
|
||||
static std::vector<std::wstring> fields(const fs::path& path) {
|
||||
nonReparse(path);
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
require(file.value != INVALID_HANDLE_VALUE, "Open Git control fields");
|
||||
const auto bytes = workspace_write::contents(file.value);
|
||||
if (bytes.size() > 1024 * 1024) throw std::runtime_error("Git control fields exceed limit");
|
||||
std::string input(bytes.begin(), bytes.end()); std::vector<std::wstring> result; size_t start = 0;
|
||||
for (;;) {
|
||||
const auto end = input.find('\n', start);
|
||||
if (end == std::string::npos) break;
|
||||
if (result.size() >= 256) throw std::runtime_error("Git control field count exceeds limit");
|
||||
result.push_back(text(input.substr(start, end - start))); start = end + 1;
|
||||
}
|
||||
if (start != input.size()) throw std::runtime_error("Incomplete Git control fields");
|
||||
return result;
|
||||
}
|
||||
static int run(const std::wstring& input, DWORD timeout) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git control is host-only");
|
||||
if (timeout < 100 || timeout > 600000) throw std::runtime_error("Invalid Git control timeout");
|
||||
const fs::path stage = fs::absolute(input);
|
||||
nonReparse(stage);
|
||||
const auto executable = stage / L"tools/git/git.exe";
|
||||
nonReparse(executable);
|
||||
auto command = quote(executable.wstring());
|
||||
for (const auto& arg : fields(stage / L"arguments")) command += L" " + quote(arg);
|
||||
if (command.size() > 32000) throw std::runtime_error("Git control argv exceeds Windows limit");
|
||||
auto environmentFields = fields(stage / L"environment");
|
||||
std::sort(environmentFields.begin(), environmentFields.end());
|
||||
std::vector<wchar_t> environment;
|
||||
for (const auto& entry : environmentFields) {
|
||||
const auto separator = entry.find(L'=');
|
||||
if (!separator || separator == std::wstring::npos || entry.substr(0, separator).find_first_not_of(L"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") != std::wstring::npos) throw std::runtime_error("Invalid Git control environment");
|
||||
environment.insert(environment.end(), entry.begin(), entry.end()); environment.push_back(0);
|
||||
}
|
||||
environment.push_back(0);
|
||||
Handle parent(parentProcess()), job(CreateJobObjectW(nullptr, nullptr));
|
||||
writeJson(stage / L"native-lease.json", "{\"version\":1,\"pid\":" + std::to_string(GetCurrentProcessId()) + ",\"root\":\"" + jsonText(stage.wstring()) + "\"}\n");
|
||||
require(job.value != nullptr, "Create Git control Job");
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY;
|
||||
limits.BasicLimitInformation.ActiveProcessLimit = 32; limits.JobMemoryLimit = static_cast<SIZE_T>(1024) * 1024 * 1024;
|
||||
require(SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) != FALSE, "Git control Job limits");
|
||||
SECURITY_ATTRIBUTES inherited = {sizeof(inherited), nullptr, TRUE};
|
||||
Handle stdinFile(CreateFileW((stage / L"input").c_str(), GENERIC_READ, FILE_SHARE_READ, &inherited, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
require(stdinFile.value != INVALID_HANDLE_VALUE, "Open Git control input");
|
||||
Handle output, error;
|
||||
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_OUTPUT_HANDLE), GetCurrentProcess(), &output.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Duplicate Git stdout");
|
||||
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_ERROR_HANDLE), GetCurrentProcess(), &error.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Duplicate Git stderr");
|
||||
AttributeList attributes;
|
||||
HANDLE handles[] = {stdinFile.value, output.value, error.value};
|
||||
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Git control handle allowlist");
|
||||
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup); startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.StartupInfo.hStdInput = stdinFile.value; startup.StartupInfo.hStdOutput = output.value; startup.StartupInfo.hStdError = error.value;
|
||||
startup.lpAttributeList = attributes.list;
|
||||
PROCESS_INFORMATION process = {};
|
||||
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW,
|
||||
environment.data(), (stage / L"workspace").c_str(), &startup.StartupInfo, &process) != FALSE, "Create Git control process");
|
||||
Handle child(process.hProcess), thread(process.hThread);
|
||||
if (!AssignProcessToJobObject(job.value, child.value)) { TerminateProcess(child.value, 125); throw std::runtime_error("Assign Git control Job"); }
|
||||
require(ResumeThread(thread.value) != static_cast<DWORD>(-1), "Resume Git control process");
|
||||
HANDLE monitored[] = {child.value, parent.value};
|
||||
const DWORD waited = WaitForMultipleObjects(2, monitored, FALSE, timeout);
|
||||
DWORD exit = waited == WAIT_TIMEOUT ? 124 : 130;
|
||||
if (waited == WAIT_OBJECT_0) require(GetExitCodeProcess(child.value, &exit) != FALSE, "Read Git control exit");
|
||||
TerminateJobObject(job.value, exit); WaitForSingleObject(child.value, 5000);
|
||||
return static_cast<int>(exit);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
'use strict'
|
||||
const fs = require('node:fs')
|
||||
const { randomUUID, createHash } = require('node:crypto')
|
||||
const prefix = '\x1eYCODE_WORKSPACE_READ_V1 '
|
||||
let selected = false
|
||||
function error(code, message) { return Object.assign(new Error(message), { code }) }
|
||||
function frame(operation, args, id) { return '\n' + prefix + JSON.stringify({ version: 1, requestId: id, operation, args }) + '\n' }
|
||||
function send(operation, args, id) {
|
||||
const bytes = Buffer.from(frame(operation, args, id))
|
||||
let offset = 0
|
||||
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset, bytes.length - offset)
|
||||
}
|
||||
function unpack(line, id) {
|
||||
let response
|
||||
try { response = JSON.parse(line) } catch { throw error('EPROTO', 'Invalid file bridge reply') }
|
||||
if (response.version !== 1 || response.requestId !== id || typeof response.ok !== 'boolean') throw error('EPROTO', 'Unexpected file bridge reply')
|
||||
if (!response.ok) throw error(response.error?.code || 'EACCES', response.error?.message || 'Workspace read denied')
|
||||
return response.value
|
||||
}
|
||||
function claim() {
|
||||
if (selected) throw error('EBUSY', 'Choose one bridge client and mode per process; stdin belongs to that client')
|
||||
selected = true
|
||||
}
|
||||
function decode(part, initial, offset) {
|
||||
if (!part || part.snapshotId !== initial.snapshotId || part.sha256 !== initial.sha256 || part.size !== initial.size || part.offset !== offset || typeof part.data !== 'string') throw error('EPROTO', 'Inconsistent read snapshot')
|
||||
const bytes = Buffer.from(part.data, 'base64')
|
||||
if (bytes.length > 16384 || !bytes.length && offset < initial.size || offset + bytes.length > initial.size || part.eof !== (offset + bytes.length === initial.size)) throw error('EPROTO', 'Invalid read chunk')
|
||||
return bytes
|
||||
}
|
||||
function complete(chunks, initial, encoding) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
if (bytes.length !== initial.size || createHash('sha256').update(bytes).digest('hex') !== initial.sha256) throw error('EPROTO', 'Read snapshot digest mismatch')
|
||||
return encoding ? bytes.toString(encoding) : bytes
|
||||
}
|
||||
function connectSync() {
|
||||
claim()
|
||||
let buffered = Buffer.alloc(0), closed = false
|
||||
function call(operation, args, id = randomUUID()) {
|
||||
if (closed) throw error('EPIPE', 'File bridge client closed')
|
||||
send(operation, args, id)
|
||||
for (;;) {
|
||||
const end = buffered.indexOf(10)
|
||||
if (end >= 0) {
|
||||
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
||||
return unpack(line, id)
|
||||
}
|
||||
if (buffered.length > 32768) throw error('EPROTO', 'File bridge reply exceeds limit')
|
||||
const bytes = Buffer.alloc(32768), length = fs.readSync(0, bytes, 0, bytes.length, null)
|
||||
if (!length) { closed = true; throw error('EPIPE', 'File bridge disconnected') }
|
||||
buffered = Buffer.concat([buffered, bytes.subarray(0, length)])
|
||||
}
|
||||
}
|
||||
return {
|
||||
call,
|
||||
readFileSync(path, encoding) {
|
||||
const initial = call('workspace.readFile', { path }), chunks = []
|
||||
try {
|
||||
let part = initial, offset = 0
|
||||
for (;;) {
|
||||
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
||||
if (part.eof) return complete(chunks, initial, encoding)
|
||||
part = call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
||||
}
|
||||
} finally { call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
||||
},
|
||||
statSync: path => call('workspace.stat', { path }),
|
||||
readdirSync: path => call('workspace.readdir', { path }).entries,
|
||||
querySync: requestId => call('operation.query', { requestId }),
|
||||
close() { closed = true }
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
claim()
|
||||
const waiting = new Map(), queue = []
|
||||
let buffered = Buffer.alloc(0), closed = false, failure
|
||||
function shutdown(reason) {
|
||||
if (closed) return
|
||||
closed = true; failure = reason
|
||||
process.stdin.off('data', receive); process.stdin.off('end', ended); process.stdin.off('error', failed); process.stdin.pause()
|
||||
process.stdin.destroy()
|
||||
for (const item of [...waiting.values(), ...queue]) item.reject(reason)
|
||||
waiting.clear(); queue.length = 0
|
||||
}
|
||||
function flush() {
|
||||
while (!closed && waiting.size < 8 && queue.length) {
|
||||
const item = queue.shift(); waiting.set(item.id, item)
|
||||
try { send(item.operation, item.args, item.id) } catch (reason) { shutdown(reason) }
|
||||
}
|
||||
if (waiting.size) { process.stdin.ref?.(); process.stdin.resume() }
|
||||
else { process.stdin.pause(); process.stdin.unref?.() }
|
||||
}
|
||||
function receive(bytes) {
|
||||
buffered = Buffer.concat([buffered, bytes])
|
||||
for (;;) {
|
||||
const end = buffered.indexOf(10)
|
||||
if (end < 0) { if (buffered.length > 32768) shutdown(error('EPROTO', 'File bridge reply exceeds limit')); return }
|
||||
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
||||
let id
|
||||
try { id = JSON.parse(line).requestId } catch { shutdown(error('EPROTO', 'Invalid file bridge reply')); return }
|
||||
const item = waiting.get(id)
|
||||
if (!item) { shutdown(error('EPROTO', 'Unexpected file bridge reply')); return }
|
||||
waiting.delete(id)
|
||||
try { item.resolve(unpack(line, id)) } catch (reason) { item.reject(reason) }
|
||||
flush()
|
||||
}
|
||||
}
|
||||
const ended = () => shutdown(error('EPIPE', 'File bridge disconnected'))
|
||||
const failed = reason => shutdown(reason)
|
||||
process.stdin.on('data', receive); process.stdin.on('end', ended); process.stdin.on('error', failed); process.stdin.pause()
|
||||
function call(operation, args, id = randomUUID()) {
|
||||
if (closed) return Promise.reject(failure || error('EPIPE', 'File bridge client closed'))
|
||||
if (queue.length + waiting.size >= 32) return Promise.reject(error('EBUSY', 'File bridge client queue full'))
|
||||
return new Promise((resolve, reject) => { queue.push({ id, operation, args, resolve, reject }); flush() })
|
||||
}
|
||||
return {
|
||||
call,
|
||||
async readFile(path, encoding) {
|
||||
const initial = await call('workspace.readFile', { path }), chunks = []
|
||||
try {
|
||||
let part = initial, offset = 0
|
||||
for (;;) {
|
||||
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
||||
if (part.eof) return complete(chunks, initial, encoding)
|
||||
part = await call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
||||
}
|
||||
} finally { await call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
||||
},
|
||||
stat: path => call('workspace.stat', { path }),
|
||||
readdir: async path => (await call('workspace.readdir', { path })).entries,
|
||||
query: requestId => call('operation.query', { requestId }),
|
||||
close() { shutdown(error('EPIPE', 'File bridge client closed')) }
|
||||
}
|
||||
}
|
||||
module.exports = { connect, connectSync }
|
||||
@@ -1,70 +0,0 @@
|
||||
'use strict'
|
||||
// Explicit preload for synchronous subprocess capture in Windows AppContainers.
|
||||
// Every subprocess still uses Node's own spawn and inherits the existing token/Job.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const cp = require('node:child_process')
|
||||
const { syncBuiltinESMExports } = require('node:module')
|
||||
const originals = { spawnSync: cp.spawnSync, execFileSync: cp.execFileSync, execSync: cp.execSync }
|
||||
const maximum = 16 * 1024 * 1024
|
||||
function captured(method, file, args, options, spawn) {
|
||||
if (options.stdio !== undefined && options.stdio !== 'pipe') return method()
|
||||
const limit = options.maxBuffer === undefined ? 1024 * 1024 : options.maxBuffer
|
||||
if (!Number.isSafeInteger(limit) || limit < 0 || limit > maximum) throw new RangeError('Sandbox process adapter maxBuffer must be between 0 and 16 MiB')
|
||||
const input = options.input === undefined ? Buffer.alloc(0) : typeof options.input === 'string'
|
||||
? Buffer.from(options.input, options.encoding && options.encoding !== 'buffer' ? options.encoding : 'utf8')
|
||||
: ArrayBuffer.isView(options.input) ? Buffer.from(options.input.buffer, options.input.byteOffset, options.input.byteLength) : null
|
||||
if (!input) throw new TypeError('Sandbox process adapter input must be a string, Buffer or ArrayBuffer view')
|
||||
if (input.length > maximum) throw new RangeError('Sandbox process adapter input exceeds 16 MiB')
|
||||
const directory = fs.mkdtempSync(path.join(process.env.TEMP, 'ycode-process-'))
|
||||
const files = ['stdin', 'stdout', 'stderr'].map(name => path.join(directory, name)), handles = []
|
||||
const encode = bytes => options.encoding && options.encoding !== 'buffer' ? bytes.toString(options.encoding) : bytes
|
||||
let answer, failure
|
||||
try {
|
||||
fs.writeFileSync(files[0], input, { flag: 'wx', mode: 0o600 })
|
||||
handles.push(fs.openSync(files[0], 'r'))
|
||||
handles.push(fs.openSync(files[1], 'wx+'))
|
||||
handles.push(fs.openSync(files[2], 'wx+'))
|
||||
const forwarded = { ...options, stdio: handles }; delete forwarded.input
|
||||
try { answer = method(forwarded) } catch (error) { failure = error }
|
||||
const read = index => {
|
||||
const buffer = Buffer.alloc(Math.min(fs.fstatSync(handles[index]).size, limit + 1))
|
||||
let length = 0
|
||||
while (length < buffer.length) { const count = fs.readSync(handles[index], buffer, length, buffer.length - length, length); if (!count) break; length += count }
|
||||
return { bytes: buffer.subarray(0, Math.min(length, limit)), overflow: length > limit }
|
||||
}
|
||||
const out = read(1), err = read(2), stdout = encode(out.bytes), stderr = encode(err.bytes)
|
||||
if (out.overflow || err.overflow) failure = Object.assign(new Error('Sandbox subprocess output exceeds maxBuffer'), { code: 'ENOBUFS', syscall: 'spawnSync', path: file, spawnargs: args })
|
||||
if (spawn) {
|
||||
if (failure && !answer) throw failure
|
||||
return { ...answer, stdout, stderr, output: [null, stdout, stderr], ...(failure ? { error: failure } : {}) }
|
||||
}
|
||||
if (failure) {
|
||||
failure.stdout = stdout; failure.stderr = stderr; failure.output = [null, stdout, stderr]
|
||||
throw failure
|
||||
}
|
||||
return stdout
|
||||
} finally {
|
||||
for (const handle of handles) try { fs.closeSync(handle) } catch {}
|
||||
for (const file of files) try { fs.unlinkSync(file) } catch {}
|
||||
try { fs.rmdirSync(directory) } catch {}
|
||||
}
|
||||
}
|
||||
function argumentsOf(args, options) {
|
||||
return Array.isArray(args) ? { args, options: options || {} } : { args: [], options: args == null ? options || {} : args }
|
||||
}
|
||||
function spawnSync(file, args, options) {
|
||||
const normalized = argumentsOf(args, options)
|
||||
return captured(forwarded => originals.spawnSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, true)
|
||||
}
|
||||
function execFileSync(file, args, options) {
|
||||
const normalized = argumentsOf(args, options)
|
||||
return captured(forwarded => originals.execFileSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, false)
|
||||
}
|
||||
function execSync(command, options = {}) {
|
||||
return captured(forwarded => originals.execSync(command, forwarded || options), command, [], options, false)
|
||||
}
|
||||
if (process.env.YCODE_SANDBOX !== 'appcontainer') throw new Error('The subprocess adapter is for the isolated YCode Node runtime')
|
||||
Object.assign(cp, { spawnSync, execFileSync, execSync })
|
||||
syncBuiltinESMExports()
|
||||
module.exports = { spawnSync, execFileSync, execSync }
|
||||
@@ -1,247 +0,0 @@
|
||||
// Test-only executable. Never distributed as the sandbox runner.
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
#include <userenv.h>
|
||||
#include <sddl.h>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static bool registryId(const std::wstring& value) {
|
||||
if (value.size() != 36 || value[8] != L'-' || value[13] != L'-' || value[18] != L'-' || value[23] != L'-') return false;
|
||||
for (size_t index = 0; index < value.size(); ++index) {
|
||||
if (index == 8 || index == 13 || index == 18 || index == 23) continue;
|
||||
if ((value[index] < L'0' || value[index] > L'9') && (value[index] < L'a' || value[index] > L'f')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static std::wstring pipeName(const std::wstring& id) { return L"\\\\.\\pipe\\YCodeSandboxTest." + id; }
|
||||
static int pipeServer(const std::wstring& id, const std::wstring& profile) {
|
||||
if (!registryId(id) || profile.size() != 42 || profile.substr(0, 10) != L"YCode.Run." || profile.substr(10).find_first_not_of(L"0123456789abcdef") != std::wstring::npos) return 2;
|
||||
PSID expected = nullptr;
|
||||
if (FAILED(DeriveAppContainerSidFromAppContainerName(profile.c_str(), &expected))) return 29;
|
||||
HANDLE token = nullptr; DWORD bytes = 0;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { FreeSid(expected); return 29; }
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); std::vector<BYTE> user(bytes);
|
||||
const bool gotUser = GetTokenInformation(token, TokenUser, user.data(), bytes, &bytes) != FALSE; CloseHandle(token);
|
||||
LPWSTR owner = nullptr, package = nullptr;
|
||||
if (!gotUser || !ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &owner) || !ConvertSidToStringSidW(expected, &package)) { if (owner) LocalFree(owner); FreeSid(expected); return 29; }
|
||||
const std::wstring sddl = L"D:P(A;;GA;;;SY)(A;;GA;;;" + std::wstring(owner) + L")(A;;0x00120003;;;" + std::wstring(package) + L")S:(ML;;NW;;;LW)";
|
||||
std::printf("PIPE_EXPECTED_SID %ls\n", package); std::fflush(stdout);
|
||||
LocalFree(owner); LocalFree(package);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) { FreeSid(expected); return 29; }
|
||||
SECURITY_ATTRIBUTES security = {sizeof(security), descriptor, FALSE};
|
||||
HANDLE pipe = CreateNamedPipeW(pipeName(id).c_str(), PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, 4096, 4096, 5000, &security);
|
||||
LocalFree(descriptor);
|
||||
if (pipe == INVALID_HANDLE_VALUE) { FreeSid(expected); return 29; }
|
||||
const bool connected = ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED;
|
||||
ULONG pid = 0; bool authenticated = false;
|
||||
if (connected && GetNamedPipeClientProcessId(pipe, &pid)) {
|
||||
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid), clientToken = nullptr;
|
||||
if (process && OpenProcessToken(process, TOKEN_QUERY, &clientToken)) {
|
||||
DWORD container = 0, needed = 0;
|
||||
if (GetTokenInformation(clientToken, TokenIsAppContainer, &container, sizeof(container), &needed) && container) {
|
||||
GetTokenInformation(clientToken, TokenAppContainerSid, nullptr, 0, &needed); std::vector<BYTE> data(needed);
|
||||
if (GetTokenInformation(clientToken, TokenAppContainerSid, data.data(), needed, &needed)) authenticated = EqualSid(reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(data.data())->TokenAppContainer, expected) != FALSE;
|
||||
}
|
||||
CloseHandle(clientToken);
|
||||
}
|
||||
if (process) CloseHandle(process);
|
||||
}
|
||||
FreeSid(expected);
|
||||
char input[8] = {}; DWORD read = 0, written = 0;
|
||||
bool ok = authenticated && ReadFile(pipe, input, sizeof(input), &read, nullptr) && read == 4 && std::string(input, read) == "PING";
|
||||
const char response[] = "BROKER_PONG";
|
||||
if (ok) ok = WriteFile(pipe, response, sizeof(response) - 1, &written, nullptr) && written == sizeof(response) - 1;
|
||||
if (ok) FlushFileBuffers(pipe);
|
||||
DisconnectNamedPipe(pipe); CloseHandle(pipe);
|
||||
std::printf("PIPE_%s\n", ok ? "AUTHENTICATED" : "REJECTED"); return ok ? 0 : 29;
|
||||
}
|
||||
static int pipeClient(const std::wstring& id) {
|
||||
if (!registryId(id)) return 2;
|
||||
HANDLE token = nullptr;
|
||||
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
|
||||
DWORD bytes = 0; GetTokenInformation(token, TokenAppContainerSid, nullptr, 0, &bytes); std::vector<BYTE> data(bytes);
|
||||
if (GetTokenInformation(token, TokenAppContainerSid, data.data(), bytes, &bytes)) {
|
||||
const PSID sid = reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(data.data())->TokenAppContainer;
|
||||
LPWSTR text = nullptr;
|
||||
if (sid && ConvertSidToStringSidW(sid, &text)) { std::printf("PIPE_CLIENT_SID %ls\n", text); LocalFree(text); std::fflush(stdout); }
|
||||
}
|
||||
CloseHandle(token);
|
||||
}
|
||||
HANDLE pipe = INVALID_HANDLE_VALUE; const auto deadline = GetTickCount64() + 4000;
|
||||
do {
|
||||
pipe = CreateFileW(pipeName(id).c_str(), FILE_READ_DATA | FILE_WRITE_DATA | SYNCHRONIZE, 0, nullptr, OPEN_EXISTING, SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, nullptr);
|
||||
if (pipe != INVALID_HANDLE_VALUE) break;
|
||||
if (GetLastError() != ERROR_FILE_NOT_FOUND && GetLastError() != ERROR_PIPE_BUSY) { std::printf("PIPE_DENIED %lu\n", GetLastError()); return 29; }
|
||||
Sleep(10);
|
||||
} while (GetTickCount64() < deadline);
|
||||
if (pipe == INVALID_HANDLE_VALUE) { std::printf("PIPE_UNAVAILABLE %lu\n", GetLastError()); return 29; }
|
||||
DWORD written = 0, read = 0; char response[64] = {};
|
||||
const bool ok = WriteFile(pipe, "PING", 4, &written, nullptr) && written == 4 && ReadFile(pipe, response, sizeof(response), &read, nullptr);
|
||||
CloseHandle(pipe);
|
||||
if (ok) std::fwrite(response, 1, read, stdout);
|
||||
return ok && std::string(response, read) == "BROKER_PONG" ? 0 : 29;
|
||||
}
|
||||
static int stdioBroker(const std::wstring& mode, const std::wstring& wideId) {
|
||||
if (!registryId(wideId)) return 2;
|
||||
std::string id;
|
||||
for (wchar_t ch : wideId) id += static_cast<char>(ch);
|
||||
if (mode == L"--broker-oversize") {
|
||||
const std::string frame = "\x1eYCODE_BROKER_V1 " + std::string(20000, 'x') + "\n";
|
||||
std::fwrite(frame.data(), 1, frame.size(), stdout); std::fflush(stdout); Sleep(5000); return 30;
|
||||
}
|
||||
if (mode == L"--broker-flood") {
|
||||
for (char index = '0'; index < '9'; ++index) {
|
||||
auto current = id; current.back() = index;
|
||||
std::printf("\x1eYCODE_BROKER_V1 {\"requestId\":\"%s\",\"operation\":\"echo\",\"payload\":\"probe-echo\"}\n", current.c_str());
|
||||
}
|
||||
std::fflush(stdout); Sleep(5000); return 30;
|
||||
}
|
||||
for (int attempt = 0; attempt < (mode == L"--broker-replay" ? 2 : 1); ++attempt) {
|
||||
std::string frame = "\x1eYCODE_BROKER_V1 {\"requestId\":\"" + id + "\",\"operation\":\"" +
|
||||
(mode == L"--broker-deny" ? "file_read" : "echo") + "\",\"payload\":\"probe-echo\"";
|
||||
if (mode == L"--broker-spoof") frame += ",\"runId\":\"unrelated-run\"";
|
||||
frame += "}\n"; std::fwrite(frame.data(), 1, frame.size(), stdout); std::fflush(stdout);
|
||||
std::string reply; char buffer[1024] = {}; DWORD read = 0;
|
||||
while (reply.find('\n') == std::string::npos && reply.size() < 16384 && ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &read, nullptr) && read) reply.append(buffer, read);
|
||||
const bool rejected = mode == L"--broker-deny" || mode == L"--broker-spoof";
|
||||
if (reply.find("\"requestId\":\"" + id + "\"") == std::string::npos || reply.find(rejected ? "\"ok\":false" : "\"ok\":true") == std::string::npos) return 30;
|
||||
}
|
||||
std::puts(mode == L"--broker-deny" || mode == L"--broker-spoof" ? "BROKER_DENIAL_RECEIVED" : "BROKER_ECHO_RECEIVED"); return 0;
|
||||
}
|
||||
static int registryProbe(const std::wstring& mode, const std::wstring& id) {
|
||||
if (!registryId(id)) return 2;
|
||||
const std::wstring path = L"Software\\YCodeSandboxTests\\" + id;
|
||||
if (mode == L"--registry-delete") {
|
||||
const LSTATUS result = RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str());
|
||||
std::printf("REG_DELETE %ld\n", result);
|
||||
RegDeleteKeyW(HKEY_CURRENT_USER, L"Software\\YCodeSandboxTests");
|
||||
return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND ? 0 : 27;
|
||||
}
|
||||
HKEY key = nullptr;
|
||||
const bool read = mode == L"--registry-read";
|
||||
const LSTATUS opened = read ? RegOpenKeyExW(HKEY_CURRENT_USER, path.c_str(), 0, KEY_QUERY_VALUE, &key)
|
||||
: RegCreateKeyExW(HKEY_CURRENT_USER, path.c_str(), 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr);
|
||||
if (opened != ERROR_SUCCESS) { std::printf("REG_OPEN_DENIED %ld\n", opened); return 27; }
|
||||
DWORD value = mode == L"--registry-create" ? 0x123456 : 99, bytes = sizeof(value), type = 0;
|
||||
const LSTATUS result = read ? RegQueryValueExW(key, L"fixture", nullptr, &type, reinterpret_cast<BYTE*>(&value), &bytes)
|
||||
: RegSetValueExW(key, L"fixture", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
|
||||
RegCloseKey(key);
|
||||
if (read) std::printf("REG_READ %ld %lu\n", result, value);
|
||||
else std::printf("REG_WRITE %ld\n", result);
|
||||
return result == ERROR_SUCCESS ? 0 : 27;
|
||||
}
|
||||
|
||||
static std::wstring quoted(const std::wstring& value) { return L"\"" + value + L"\""; }
|
||||
static int writeFile(const wchar_t* path) {
|
||||
HANDLE file = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) { std::printf("WRITE_DENIED %lu\n", GetLastError()); return 23; }
|
||||
const char text[] = "native-probe"; DWORD written = 0;
|
||||
const bool ok = WriteFile(file, text, sizeof(text) - 1, &written, nullptr) != FALSE;
|
||||
CloseHandle(file); return ok ? 0 : 23;
|
||||
}
|
||||
static int spawnChild(const std::wstring& arguments, bool wait, DWORD flags = 0) {
|
||||
wchar_t executable[32768] = {};
|
||||
if (!GetModuleFileNameW(nullptr, executable, 32768)) return 25;
|
||||
std::wstring command = quoted(executable) + L" " + arguments;
|
||||
STARTUPINFOW startup = {}; startup.cb = sizeof(startup);
|
||||
startup.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE); startup.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
|
||||
PROCESS_INFORMATION process = {};
|
||||
if (!CreateProcessW(executable, command.data(), nullptr, nullptr, TRUE, flags, nullptr, nullptr, &startup, &process)) {
|
||||
std::printf("SPAWN_DENIED %lu\n", GetLastError()); return 25;
|
||||
}
|
||||
CloseHandle(process.hThread);
|
||||
DWORD exit = 0;
|
||||
if (wait) { WaitForSingleObject(process.hProcess, 10000); GetExitCodeProcess(process.hProcess, &exit); }
|
||||
CloseHandle(process.hProcess); return static_cast<int>(exit);
|
||||
}
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
if (argc < 2) return 2;
|
||||
const std::wstring mode = argv[1];
|
||||
if (mode.rfind(L"--broker-", 0) == 0 && argc == 3) return stdioBroker(mode, argv[2]);
|
||||
if (mode == L"--pipe-server" && argc == 4) return pipeServer(argv[2], argv[3]);
|
||||
if (mode == L"--pipe-client" && argc == 3) return pipeClient(argv[2]);
|
||||
if (mode == L"--registry-create" || mode == L"--registry-read" || mode == L"--registry-write" || mode == L"--registry-delete") return argc == 3 ? registryProbe(mode, argv[2]) : 2;
|
||||
if (mode == L"--hold-directory" && argc == 5) {
|
||||
HANDLE directory = CreateFileW(argv[2], FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
|
||||
if (directory == INVALID_HANDLE_VALUE) return 28;
|
||||
FILE_ATTRIBUTE_TAG_INFO tag = {};
|
||||
if (!GetFileInformationByHandleEx(directory, FileAttributeTagInfo, &tag, sizeof(tag)) || (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { CloseHandle(directory); return 28; }
|
||||
if (writeFile(argv[3])) { CloseHandle(directory); return 28; }
|
||||
const ULONGLONG deadline = GetTickCount64() + 5000;
|
||||
while (GetFileAttributesW(argv[4]) == INVALID_FILE_ATTRIBUTES && GetTickCount64() < deadline) Sleep(10);
|
||||
CloseHandle(directory); return 0;
|
||||
}
|
||||
if (mode == L"--host-facts") {
|
||||
HANDLE token = nullptr; TOKEN_ELEVATION elevation = {}; DWORD bytes = 0, container = 0;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return 3;
|
||||
const bool elevated = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &bytes) && elevation.TokenIsElevated;
|
||||
GetTokenInformation(token, TokenIsAppContainer, &container, sizeof(container), &bytes); CloseHandle(token);
|
||||
std::printf("{\"elevated\":%s,\"appContainer\":%s}\n", elevated ? "true" : "false", container ? "true" : "false"); return 0;
|
||||
}
|
||||
if (mode == L"--info") {
|
||||
HANDLE token = nullptr; DWORD container = 0, bytes = 0; BOOL job = FALSE;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return 3;
|
||||
GetTokenInformation(token, TokenIsAppContainer, &container, sizeof(container), &bytes); CloseHandle(token);
|
||||
IsProcessInJob(GetCurrentProcess(), nullptr, &job);
|
||||
wchar_t secret[128] = {};
|
||||
const bool inherited = GetEnvironmentVariableW(L"YCODE_FAKE_SECRET", secret, 128) != 0;
|
||||
std::printf("{\"appContainer\":%s,\"job\":%s,\"secretInherited\":%s}\n", container ? "true" : "false", job ? "true" : "false", inherited ? "true" : "false");
|
||||
return container && job && !inherited ? 0 : 4;
|
||||
}
|
||||
if (mode == L"--write" && argc == 3) return writeFile(argv[2]);
|
||||
if (mode == L"--delete" && argc == 3) {
|
||||
const BOOL deleted = DeleteFileW(argv[2]);
|
||||
std::printf("DELETE_%s %lu\n", deleted ? "OK" : "DENIED", deleted ? 0 : GetLastError());
|
||||
return deleted ? 0 : 26;
|
||||
}
|
||||
if (mode == L"--paths") {
|
||||
wchar_t cwd[32768] = {}, full[32768] = {};
|
||||
GetCurrentDirectoryW(32768, cwd); GetFullPathNameW(L".", 32768, full, nullptr);
|
||||
std::wprintf(L"CWD=%ls FULL=%ls\n", cwd, full);
|
||||
const DWORD attributes = GetFileAttributesW(L".");
|
||||
std::printf("ATTR=%lu ERROR=%lu\n", attributes, GetLastError());
|
||||
WIN32_FIND_DATAW data = {};
|
||||
HANDLE found = FindFirstFileW(L".\\*", &data);
|
||||
std::printf("FIND=%s ERROR=%lu\n", found == INVALID_HANDLE_VALUE ? "DENIED" : "OK", GetLastError());
|
||||
if (found != INVALID_HANDLE_VALUE) FindClose(found);
|
||||
return 0;
|
||||
}
|
||||
if (mode == L"--read" && argc == 3) {
|
||||
HANDLE file = CreateFileW(argv[2], GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) { std::printf("READ_DENIED %lu\n", GetLastError()); return 22; }
|
||||
char buffer[256] = {}; DWORD bytes = 0;
|
||||
const bool ok = ReadFile(file, buffer, sizeof(buffer), &bytes, nullptr) != FALSE; CloseHandle(file);
|
||||
if (ok) std::fwrite(buffer, 1, bytes, stdout);
|
||||
return ok ? 0 : 22;
|
||||
}
|
||||
if (mode == L"--tcp" && argc == 3) {
|
||||
WSADATA data = {}; if (WSAStartup(MAKEWORD(2, 2), &data)) return 24;
|
||||
SOCKET socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (socket == INVALID_SOCKET) { WSACleanup(); return 24; }
|
||||
sockaddr_in target = {}; target.sin_family = AF_INET; target.sin_port = htons(static_cast<u_short>(_wtoi(argv[2]))); target.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
u_long nonblocking = 1; ioctlsocket(socket, FIONBIO, &nonblocking);
|
||||
int connected = connect(socket, reinterpret_cast<sockaddr*>(&target), sizeof(target));
|
||||
int error = connected == 0 ? 0 : WSAGetLastError();
|
||||
if (connected != 0 && error == WSAEWOULDBLOCK) {
|
||||
fd_set ready, failed; FD_ZERO(&ready); FD_ZERO(&failed); FD_SET(socket, &ready); FD_SET(socket, &failed);
|
||||
timeval timeout = {1, 0};
|
||||
if (select(0, nullptr, &ready, &failed, &timeout) > 0) {
|
||||
int length = sizeof(error); getsockopt(socket, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&error), &length);
|
||||
connected = error == 0 ? 0 : SOCKET_ERROR;
|
||||
} else { connected = SOCKET_ERROR; error = WSAETIMEDOUT; }
|
||||
}
|
||||
std::printf("TCP_%s %d\n", connected == 0 ? "CONNECTED" : "DENIED", error);
|
||||
closesocket(socket); WSACleanup(); return connected == 0 ? 0 : 24;
|
||||
}
|
||||
if (mode == L"--delay" && argc == 4) { Sleep(static_cast<DWORD>(_wtoi(argv[2]))); return writeFile(argv[3]); }
|
||||
if (mode == L"--spawn-write" && argc == 3) return spawnChild(L"--write " + quoted(argv[2]), true);
|
||||
if (mode == L"--spawn-delayed" && argc == 3) return spawnChild(L"--delay 1400 " + quoted(argv[2]), false);
|
||||
if (mode == L"--breakaway" && argc == 3) return spawnChild(L"--write " + quoted(argv[2]), true, CREATE_BREAKAWAY_FROM_JOB);
|
||||
return 2;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Keep private Windows temporary directories accessible to this AppContainer.
|
||||
|
||||
Python 3.13+ gives mode 0o700 an explicit user/admin DACL on Windows. That
|
||||
omits the AppContainer SID. Inside our stage, inherit the native private parent
|
||||
DACL instead. Kernel permissions and the Job remain the security boundary.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.name == "nt" and os.environ.get("YCODE_SANDBOX") == "appcontainer":
|
||||
_mkdir = os.mkdir
|
||||
_stage = os.path.normcase(os.path.abspath(os.environ["YCODE_SANDBOX_ROOT"]))
|
||||
|
||||
def _private_mkdir(path, mode=0o777, *, dir_fd=None):
|
||||
if mode == 0o700 and dir_fd is None:
|
||||
target = os.path.normcase(os.path.abspath(os.fsdecode(path)))
|
||||
try:
|
||||
if os.path.commonpath((_stage, target)) == _stage:
|
||||
mode = 0o777
|
||||
except ValueError:
|
||||
pass
|
||||
return _mkdir(path, mode, dir_fd=dir_fd)
|
||||
|
||||
os.mkdir = _private_mkdir
|
||||
@@ -1,444 +0,0 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <userenv.h>
|
||||
#include <sddl.h>
|
||||
#include <aclapi.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
struct Handle {
|
||||
HANDLE value = nullptr;
|
||||
Handle() = default;
|
||||
explicit Handle(HANDLE handle) : value(handle) {}
|
||||
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
|
||||
Handle(const Handle&) = delete;
|
||||
Handle& operator=(const Handle&) = delete;
|
||||
};
|
||||
static void require(bool ok, const char* operation) {
|
||||
if (!ok) throw std::runtime_error(std::string(operation) + ": win32=" + std::to_string(GetLastError()));
|
||||
}
|
||||
static void security(DWORD error, const char* operation) {
|
||||
if (error != ERROR_SUCCESS) throw std::runtime_error(std::string(operation) + ": win32=" + std::to_string(error));
|
||||
}
|
||||
static bool inContainer(HANDLE process) {
|
||||
Handle token;
|
||||
require(OpenProcessToken(process, TOKEN_QUERY, &token.value) != FALSE, "OpenProcessToken");
|
||||
DWORD value = 0, bytes = 0;
|
||||
require(GetTokenInformation(token.value, TokenIsAppContainer, &value, sizeof(value), &bytes) != FALSE, "TokenIsAppContainer");
|
||||
return value != 0;
|
||||
}
|
||||
static bool matchesContainer(HANDLE process, PSID expected) {
|
||||
Handle token;
|
||||
require(OpenProcessToken(process, TOKEN_QUERY, &token.value) != FALSE, "Open child token");
|
||||
DWORD bytes = 0; GetTokenInformation(token.value, TokenAppContainerSid, nullptr, 0, &bytes);
|
||||
std::vector<BYTE> buffer(bytes);
|
||||
require(GetTokenInformation(token.value, TokenAppContainerSid, buffer.data(), bytes, &bytes) != FALSE, "Child AppContainer SID");
|
||||
return EqualSid(reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(buffer.data())->TokenAppContainer, expected) != FALSE;
|
||||
}
|
||||
static std::wstring quote(const std::wstring& value) {
|
||||
std::wstring result = L"\"";
|
||||
size_t slashes = 0;
|
||||
for (wchar_t ch : value) {
|
||||
if (ch == L'\\') { ++slashes; continue; }
|
||||
if (ch == L'\"') result.append(slashes * 2 + 1, L'\\');
|
||||
else result.append(slashes, L'\\');
|
||||
slashes = 0;
|
||||
result += ch;
|
||||
}
|
||||
result.append(slashes * 2, L'\\');
|
||||
return result + L"\"";
|
||||
}
|
||||
static std::string jsonText(const std::wstring& value) {
|
||||
const int count = WideCharToMultiByte(CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string bytes(count, 0), escaped;
|
||||
WideCharToMultiByte(CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), bytes.data(), count, nullptr, nullptr);
|
||||
for (char ch : bytes) { if (ch == '\\' || ch == '"') escaped += '\\'; escaped += ch; }
|
||||
return escaped;
|
||||
}
|
||||
static void writeJson(const fs::path& target, const std::string& json) {
|
||||
Handle output(CreateFileW(target.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
require(output.value != INVALID_HANDLE_VALUE, "Create control report");
|
||||
DWORD written = 0;
|
||||
require(WriteFile(output.value, json.data(), static_cast<DWORD>(json.size()), &written, nullptr) && written == json.size(), "Write control report");
|
||||
require(FlushFileBuffers(output.value) != FALSE, "Flush control report");
|
||||
}
|
||||
static std::wstring windowsDirectory() {
|
||||
wchar_t buffer[MAX_PATH] = {};
|
||||
require(GetWindowsDirectoryW(buffer, MAX_PATH) != 0, "GetWindowsDirectory");
|
||||
return buffer;
|
||||
}
|
||||
static void nonReparse(const fs::path& target) {
|
||||
auto current = target;
|
||||
for (;;) {
|
||||
const DWORD attributes = GetFileAttributesW(current.c_str());
|
||||
require(attributes != INVALID_FILE_ATTRIBUTES, "GetFileAttributes");
|
||||
if (attributes & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Reparse point in sandbox control path");
|
||||
const auto parent = current.parent_path();
|
||||
if (parent == current || parent.empty()) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
static void privateDirectory(const fs::path& path) {
|
||||
Handle token;
|
||||
require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) != FALSE, "OpenProcessToken");
|
||||
DWORD bytes = 0;
|
||||
GetTokenInformation(token.value, TokenUser, nullptr, 0, &bytes);
|
||||
std::vector<BYTE> user(bytes);
|
||||
require(GetTokenInformation(token.value, TokenUser, user.data(), bytes, &bytes) != FALSE, "TokenUser");
|
||||
LPWSTR sid = nullptr;
|
||||
require(ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid) != FALSE, "ConvertSidToStringSid");
|
||||
const std::wstring sddl = L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;" + std::wstring(sid) + L")";
|
||||
LocalFree(sid);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr) != FALSE, "Private DACL");
|
||||
PACL acl = nullptr; BOOL present = FALSE, defaulted = FALSE;
|
||||
require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) != FALSE, "GetSecurityDescriptorDacl");
|
||||
const DWORD error = SetNamedSecurityInfoW(const_cast<LPWSTR>(path.c_str()), SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr);
|
||||
LocalFree(descriptor);
|
||||
security(error, "Set private control DACL");
|
||||
}
|
||||
static void grantStage(const fs::path& target, PSID sid, bool writable = true) {
|
||||
if (!writable) privateDirectory(target);
|
||||
PSECURITY_DESCRIPTOR old = nullptr;
|
||||
PACL acl = nullptr, next = nullptr;
|
||||
security(GetNamedSecurityInfoW(target.c_str(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &acl, nullptr, &old), "Read stage DACL");
|
||||
EXPLICIT_ACCESSW access = {};
|
||||
access.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE;
|
||||
if (writable) access.grfAccessPermissions |= FILE_GENERIC_WRITE | DELETE;
|
||||
access.grfAccessMode = GRANT_ACCESS;
|
||||
access.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
|
||||
access.Trustee.TrusteeForm = TRUSTEE_IS_SID;
|
||||
access.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN;
|
||||
access.Trustee.ptstrName = static_cast<LPWSTR>(sid);
|
||||
const DWORD combined = SetEntriesInAclW(1, &access, acl, &next);
|
||||
if (combined != ERROR_SUCCESS) { LocalFree(old); security(combined, "Build stage DACL"); }
|
||||
const DWORD applied = SetNamedSecurityInfoW(const_cast<LPWSTR>(target.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, next, nullptr);
|
||||
LocalFree(next); LocalFree(old);
|
||||
security(applied, "Grant stage DACL");
|
||||
PSECURITY_DESCRIPTOR label = nullptr;
|
||||
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(L"S:(ML;OICI;NW;;;LW)", SDDL_REVISION_1, &label, nullptr) != FALSE, "Stage integrity label");
|
||||
PACL sacl = nullptr; BOOL present = FALSE, defaulted = FALSE;
|
||||
require(GetSecurityDescriptorSacl(label, &present, &sacl, &defaulted) != FALSE, "GetSecurityDescriptorSacl");
|
||||
const DWORD labeled = SetNamedSecurityInfoW(const_cast<LPWSTR>(target.c_str()), SE_FILE_OBJECT, LABEL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, sacl);
|
||||
LocalFree(label);
|
||||
security(labeled, "Set stage integrity label");
|
||||
}
|
||||
struct Profile {
|
||||
std::wstring name;
|
||||
PSID sid = nullptr;
|
||||
explicit Profile(std::wstring value) : name(std::move(value)) {
|
||||
if (name.size() != 42 || name.substr(0, 10) != L"YCode.Run." ||
|
||||
name.substr(10).find_first_not_of(L"0123456789abcdef") != std::wstring::npos) throw std::runtime_error("Invalid profile identity");
|
||||
const HRESULT hr = CreateAppContainerProfile(name.c_str(), L"YCode isolated command", L"YCode per-command workspace snapshot", nullptr, 0, &sid);
|
||||
if (FAILED(hr)) throw std::runtime_error("CreateAppContainerProfile: hresult=" + std::to_string(static_cast<unsigned long>(hr)));
|
||||
}
|
||||
bool remove() {
|
||||
if (!sid) return true;
|
||||
FreeSid(sid); sid = nullptr;
|
||||
return SUCCEEDED(DeleteAppContainerProfile(name.c_str()));
|
||||
}
|
||||
~Profile() { remove(); }
|
||||
};
|
||||
static bool validProfile(const std::wstring& name) {
|
||||
return name.size() == 42 && name.substr(0, 10) == L"YCode.Run." && name.substr(10).find_first_not_of(L"0123456789abcdef") == std::wstring::npos;
|
||||
}
|
||||
static HANDLE parentProcess() {
|
||||
Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0));
|
||||
require(snapshot.value != INVALID_HANDLE_VALUE, "CreateToolhelp32Snapshot");
|
||||
PROCESSENTRY32W entry = {}; entry.dwSize = sizeof(entry);
|
||||
require(Process32FirstW(snapshot.value, &entry) != FALSE, "Process32First");
|
||||
do {
|
||||
if (entry.th32ProcessID == GetCurrentProcessId()) {
|
||||
HANDLE parent = OpenProcess(SYNCHRONIZE, FALSE, entry.th32ParentProcessID);
|
||||
require(parent != nullptr, "Open parent monitor");
|
||||
return parent;
|
||||
}
|
||||
} while (Process32NextW(snapshot.value, &entry));
|
||||
throw std::runtime_error("Parent process identity unavailable");
|
||||
}
|
||||
struct Drive {
|
||||
std::wstring name, target;
|
||||
explicit Drive(const fs::path& stage) : target(L"\\??\\" + stage.wstring()) {
|
||||
Handle mutex(CreateMutexW(nullptr, FALSE, L"Local\\YCode.Sandbox.DriveAllocator.v1"));
|
||||
require(mutex.value != nullptr, "Create drive allocator mutex");
|
||||
const DWORD lock = WaitForSingleObject(mutex.value, 5000);
|
||||
if (lock != WAIT_OBJECT_0 && lock != WAIT_ABANDONED) throw std::runtime_error("Drive allocator busy");
|
||||
for (wchar_t letter = L'Z'; letter >= L'D'; --letter) {
|
||||
const std::wstring candidate = std::wstring(1, letter) + L":";
|
||||
wchar_t existing[32768] = {};
|
||||
if (QueryDosDeviceW(candidate.c_str(), existing, 32768)) continue;
|
||||
if (DefineDosDeviceW(DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, candidate.c_str(), target.c_str())) { name = candidate; break; }
|
||||
}
|
||||
ReleaseMutex(mutex.value);
|
||||
if (name.empty()) throw std::runtime_error("No free drive for sandbox workspace");
|
||||
}
|
||||
bool release() {
|
||||
if (name.empty()) return true;
|
||||
const bool removed = DefineDosDeviceW(DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE | DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, name.c_str(), target.c_str()) != FALSE;
|
||||
if (removed) name.clear();
|
||||
return removed;
|
||||
}
|
||||
~Drive() { release(); }
|
||||
fs::path root() const { return fs::path(name + L"\\"); }
|
||||
};
|
||||
static std::vector<wchar_t> childEnvironment(const fs::path& stage) {
|
||||
const auto win = windowsDirectory();
|
||||
std::vector<std::wstring> entries = {
|
||||
L"=" + stage.root_name().wstring() + L"=" + (stage / L"workspace").wstring(),
|
||||
L"APPDATA=" + (stage / L"home/AppData/Roaming").wstring(), L"COMSPEC=" + win + L"\\System32\\cmd.exe",
|
||||
L"HOME=" + (stage / L"home").wstring(), L"LOCALAPPDATA=" + (stage / L"home/AppData/Local").wstring(),
|
||||
L"PATH=" + (stage / L"tools/node").wstring() + L";" + (stage / L"tools/python").wstring() + L";" + (stage / L"tools/git").wstring() + L";" + (stage / L"tools").wstring() + L";" + win + L"\\System32;" + win + L"\\System32\\WindowsPowerShell\\v1.0",
|
||||
L"NODE_REPL_HISTORY=" + (stage / L"home/node_repl_history").wstring(),
|
||||
L"NPM_CONFIG_CACHE=" + (stage / L"home/npm-cache").wstring(), L"NPM_CONFIG_OFFLINE=true",
|
||||
L"NPM_CONFIG_USERCONFIG=" + (stage / L"home/npmrc").wstring(),
|
||||
L"PYTHONHOME=" + (stage / L"tools/python").wstring(), L"PYTHONNOUSERSITE=1", L"PYTHONDONTWRITEBYTECODE=1",
|
||||
L"PIP_CONFIG_FILE=NUL", L"PIP_NO_INPUT=1", L"PIP_NO_INDEX=1", L"PIP_DISABLE_PIP_VERSION_CHECK=1",
|
||||
L"PIP_CACHE_DIR=" + (stage / L"home/pip-cache").wstring(),
|
||||
L"PYTHONPATH=" + (stage / L"tools/python-adapter").wstring() + L";" + (stage / L"home/python-site").wstring(),
|
||||
L"GIT_CONFIG_NOSYSTEM=1", L"GIT_CONFIG_GLOBAL=NUL", L"GIT_CONFIG_SYSTEM=NUL", L"GIT_TERMINAL_PROMPT=0",
|
||||
L"GIT_OPTIONAL_LOCKS=0", L"GIT_PAGER=", L"GIT_EDITOR=" + (stage / L"tools/ycode-git-editor.exe").generic_wstring(), L"GIT_SEQUENCE_EDITOR=" + (stage / L"tools/ycode-git-editor.exe").generic_wstring(), L"GIT_ALLOW_PROTOCOL=",
|
||||
L"GIT_EXEC_PATH=" + (stage / L"tools/git/empty-hooks").wstring(), L"GIT_TEMPLATE_DIR=" + (stage / L"tools/git/empty-template").wstring(),
|
||||
L"GIT_CONFIG_COUNT=7", L"GIT_CONFIG_KEY_0=core.hooksPath", L"GIT_CONFIG_VALUE_0=" + (stage / L"tools/git/empty-hooks").wstring(),
|
||||
L"GIT_CONFIG_KEY_1=core.fsmonitor", L"GIT_CONFIG_VALUE_1=false", L"GIT_CONFIG_KEY_2=credential.helper", L"GIT_CONFIG_VALUE_2=",
|
||||
L"GIT_CONFIG_KEY_3=commit.gpgSign", L"GIT_CONFIG_VALUE_3=false", L"GIT_CONFIG_KEY_4=tag.gpgSign", L"GIT_CONFIG_VALUE_4=false",
|
||||
L"GIT_CONFIG_KEY_5=maintenance.auto", L"GIT_CONFIG_VALUE_5=false",
|
||||
L"GIT_CONFIG_KEY_6=diff.autoRefreshIndex", L"GIT_CONFIG_VALUE_6=false",
|
||||
L"PATHEXT=.COM;.EXE;.BAT;.CMD", L"SystemRoot=" + win, L"TEMP=" + (stage / L"tmp").wstring(),
|
||||
L"TMP=" + (stage / L"tmp").wstring(), L"USERPROFILE=" + (stage / L"home").wstring(),
|
||||
L"WINDIR=" + win, L"YCODE_SANDBOX=appcontainer", L"YCODE_SANDBOX_BRIDGE=" + (stage / L"tools/ycode-bridge.cjs").wstring(),
|
||||
L"YCODE_SANDBOX_PROCESS=" + (stage / L"tools/ycode-process.cjs").wstring(), L"YCODE_SANDBOX_ROOT=" + stage.wstring()
|
||||
};
|
||||
if (fs::is_directory(stage / L"repository")) {
|
||||
entries.push_back(L"GIT_DIR=" + (stage / L"repository").wstring());
|
||||
entries.push_back(L"GIT_WORK_TREE=" + (stage / L"workspace").wstring());
|
||||
}
|
||||
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { return _wcsicmp(a.c_str(), b.c_str()) < 0; });
|
||||
std::vector<wchar_t> block;
|
||||
for (const auto& entry : entries) { block.insert(block.end(), entry.begin(), entry.end()); block.push_back(0); }
|
||||
block.push_back(0);
|
||||
return block;
|
||||
}
|
||||
static void forward(HANDLE input, HANDLE output) {
|
||||
char buffer[16384]; DWORD read = 0;
|
||||
while (ReadFile(input, buffer, sizeof(buffer), &read, nullptr) && read) {
|
||||
DWORD offset = 0;
|
||||
while (offset < read) {
|
||||
DWORD written = 0;
|
||||
if (!WriteFile(output, buffer + offset, read - offset, &written, nullptr) || !written) return;
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool brokerInput(const std::string& line, std::string& decoded) {
|
||||
if (line.size() > 65536 || line.rfind("input:", 0) != 0) return false;
|
||||
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const auto encoded = line.substr(6);
|
||||
if (encoded.empty() || encoded.size() % 4) return false;
|
||||
unsigned int accumulator = 0; int bits = 0; bool padding = false; size_t paddingCount = 0;
|
||||
for (size_t index = 0; index < encoded.size(); ++index) {
|
||||
const char ch = encoded[index];
|
||||
if (ch == '=') { padding = true; if (++paddingCount > 2 || index < encoded.size() - 2) return false; continue; }
|
||||
const auto value = alphabet.find(ch);
|
||||
if (padding || value == std::string::npos) return false;
|
||||
accumulator = (accumulator << 6) | static_cast<unsigned int>(value); bits += 6;
|
||||
if (bits >= 8) { bits -= 8; decoded += static_cast<char>((accumulator >> bits) & 255); }
|
||||
}
|
||||
return decoded.size() <= 32768 && bits == static_cast<int>(paddingCount) * 2 && (bits == 0 || (accumulator & ((1U << bits) - 1)) == 0);
|
||||
}
|
||||
struct AttributeList {
|
||||
std::vector<BYTE> bytes;
|
||||
LPPROC_THREAD_ATTRIBUTE_LIST list = nullptr;
|
||||
AttributeList() {
|
||||
SIZE_T size = 0; InitializeProcThreadAttributeList(nullptr, 2, 0, &size);
|
||||
bytes.resize(size); list = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(bytes.data());
|
||||
require(InitializeProcThreadAttributeList(list, 2, 0, &size) != FALSE, "InitializeProcThreadAttributeList");
|
||||
}
|
||||
~AttributeList() { if (list) DeleteProcThreadAttributeList(list); }
|
||||
};
|
||||
namespace git_control { static std::vector<std::wstring> fields(const fs::path& path); }
|
||||
static int run(const std::wstring& name, const fs::path& stage, const fs::path& report, DWORD timeout, bool broker = false) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Runner cannot broker from inside an AppContainer");
|
||||
if (!stage.is_absolute() || report.parent_path() != stage.parent_path() || report.filename() != L"result.json" ||
|
||||
stage.filename() != L"stage" || fs::exists(report)) throw std::runtime_error("Invalid sandbox control layout");
|
||||
nonReparse(stage);
|
||||
privateDirectory(stage.parent_path());
|
||||
Profile profile(name);
|
||||
// Windows derives the AppContainer TEMP path from LOCALAPPDATA even when TEMP
|
||||
// was supplied explicitly. Provision that private path before applying ACLs.
|
||||
fs::create_directories(stage / L"home/AppData/Local/Packages" / name / L"AC/Temp");
|
||||
grantStage(stage, profile.sid);
|
||||
for (const auto& entry : fs::recursive_directory_iterator(stage)) {
|
||||
if (GetFileAttributesW(entry.path().c_str()) & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Reparse point in stage");
|
||||
const auto relative = entry.path().lexically_relative(stage).generic_wstring();
|
||||
const bool tool = relative == L"tools" || relative.rfind(L"tools/", 0) == 0 ||
|
||||
relative == L"workspace/node_modules" || relative.rfind(L"workspace/node_modules/", 0) == 0 ||
|
||||
relative == L"home/python-site" || relative.rfind(L"home/python-site/", 0) == 0;
|
||||
grantStage(entry.path(), profile.sid, !tool);
|
||||
}
|
||||
Drive drive(stage);
|
||||
writeJson(stage.parent_path() / L"lease.json", "{\"version\":1,\"pid\":" + std::to_string(GetCurrentProcessId()) +
|
||||
",\"profile\":\"" + jsonText(name) + "\",\"drive\":\"" + jsonText(drive.name) + "\",\"stage\":\"" + jsonText(stage.wstring()) + "\"}\n");
|
||||
Handle parent(parentProcess());
|
||||
Handle job(CreateJobObjectW(nullptr, nullptr)); require(job.value != nullptr, "CreateJobObject");
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY;
|
||||
limits.BasicLimitInformation.ActiveProcessLimit = 32;
|
||||
limits.JobMemoryLimit = static_cast<SIZE_T>(1024) * 1024 * 1024;
|
||||
require(SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) != FALSE, "Job limits");
|
||||
JOBOBJECT_BASIC_UI_RESTRICTIONS ui = {}; ui.UIRestrictionsClass = JOB_OBJECT_UILIMIT_ALL;
|
||||
require(SetInformationJobObject(job.value, JobObjectBasicUIRestrictions, &ui, sizeof(ui)) != FALSE, "Job UI limits");
|
||||
SECURITY_ATTRIBUTES inherited = {sizeof(inherited), nullptr, TRUE};
|
||||
Handle outRead, outWrite, errRead, errWrite;
|
||||
require(CreatePipe(&outRead.value, &outWrite.value, &inherited, 0) != FALSE, "stdout pipe");
|
||||
require(CreatePipe(&errRead.value, &errWrite.value, &inherited, 0) != FALSE, "stderr pipe");
|
||||
require(SetHandleInformation(outRead.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "stdout inheritance");
|
||||
require(SetHandleInformation(errRead.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "stderr inheritance");
|
||||
Handle input, inputWrite;
|
||||
if (broker) {
|
||||
require(CreatePipe(&input.value, &inputWrite.value, &inherited, 65536) != FALSE, "broker child input");
|
||||
require(SetHandleInformation(inputWrite.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "broker input writer inheritance");
|
||||
} else input.value = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &inherited, OPEN_EXISTING, 0, nullptr);
|
||||
require(input.value != INVALID_HANDLE_VALUE, "NUL input");
|
||||
AttributeList attributes;
|
||||
SECURITY_CAPABILITIES caps = {}; caps.AppContainerSid = profile.sid;
|
||||
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &caps, sizeof(caps), nullptr, nullptr) != FALSE, "AppContainer capability attribute");
|
||||
HANDLE handles[] = {input.value, outWrite.value, errWrite.value};
|
||||
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Handle allowlist attribute");
|
||||
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.StartupInfo.hStdInput = input.value; startup.StartupInfo.hStdOutput = outWrite.value; startup.StartupInfo.hStdError = errWrite.value;
|
||||
startup.lpAttributeList = attributes.list;
|
||||
auto executable = windowsDirectory() + L"\\System32\\cmd.exe";
|
||||
std::wstring command = quote(executable) + L" /d /s /c \"\"" + (drive.root() / L"command.cmd").wstring() + L"\"\"";
|
||||
if (fs::exists(stage / L"git-arguments")) {
|
||||
executable = (drive.root() / L"tools/git/git.exe").wstring(); command = quote(executable);
|
||||
for (const auto& arg : git_control::fields(stage / L"git-arguments")) command += L" " + quote(arg);
|
||||
if (command.size() > 32000) throw std::runtime_error("Isolated Git argv exceeds Windows limit");
|
||||
}
|
||||
auto environment = childEnvironment(drive.root());
|
||||
PROCESS_INFORMATION process = {};
|
||||
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW,
|
||||
environment.data(), (drive.root() / L"workspace").c_str(), &startup.StartupInfo, &process) != FALSE, "Create AppContainer process");
|
||||
Handle child(process.hProcess), thread(process.hThread);
|
||||
if (!AssignProcessToJobObject(job.value, child.value) || !inContainer(child.value) || !matchesContainer(child.value, profile.sid)) {
|
||||
TerminateProcess(child.value, 125); throw std::runtime_error("Child isolation verification failed");
|
||||
}
|
||||
CloseHandle(outWrite.value); outWrite.value = nullptr;
|
||||
CloseHandle(errWrite.value); errWrite.value = nullptr;
|
||||
const ULONGLONG started = GetTickCount64();
|
||||
require(ResumeThread(thread.value) != static_cast<DWORD>(-1), "Resume isolated process");
|
||||
std::thread stdoutThread(forward, outRead.value, GetStdHandle(STD_OUTPUT_HANDLE));
|
||||
std::thread stderrThread(forward, errRead.value, GetStdHandle(STD_ERROR_HANDLE));
|
||||
Handle cancelled(CreateEventW(nullptr, TRUE, FALSE, nullptr));
|
||||
std::thread controlThread([&cancelled, &inputWrite, broker]() {
|
||||
char buffer[4096] = {}; DWORD read = 0; std::string pending;
|
||||
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &read, nullptr) && read) {
|
||||
pending.append(buffer, read);
|
||||
if (pending.size() > 65536) { SetEvent(cancelled.value); return; }
|
||||
size_t newline = 0;
|
||||
while ((newline = pending.find('\n')) != std::string::npos) {
|
||||
const auto line = pending.substr(0, newline); pending.erase(0, newline + 1);
|
||||
if (line == "cancel") { SetEvent(cancelled.value); return; }
|
||||
std::string decoded;
|
||||
if (!broker || !brokerInput(line, decoded)) { SetEvent(cancelled.value); return; }
|
||||
DWORD written = 0;
|
||||
if (!WriteFile(inputWrite.value, decoded.data(), static_cast<DWORD>(decoded.size()), &written, nullptr) || written != decoded.size()) { SetEvent(cancelled.value); return; }
|
||||
}
|
||||
}
|
||||
});
|
||||
HANDLE monitored[] = {child.value, parent.value, cancelled.value};
|
||||
const DWORD waited = WaitForMultipleObjects(3, monitored, FALSE, timeout);
|
||||
DWORD exit = 125;
|
||||
const char* status = "error";
|
||||
if (waited == WAIT_OBJECT_0) { GetExitCodeProcess(child.value, &exit); status = "completed"; }
|
||||
else if (waited == WAIT_TIMEOUT) { exit = 124; status = "timeout"; }
|
||||
else if (waited == WAIT_OBJECT_0 + 1) { exit = 130; status = "parent-exited"; }
|
||||
else if (waited == WAIT_OBJECT_0 + 2) { exit = 130; status = "cancelled"; }
|
||||
// No breakaway flags: close/terminate the job also stops still-running descendants.
|
||||
const bool terminated = TerminateJobObject(job.value, exit) != FALSE;
|
||||
WaitForSingleObject(child.value, 5000);
|
||||
bool quiescent = false;
|
||||
for (int attempt = 0; terminated && attempt < 500; ++attempt) {
|
||||
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting = {};
|
||||
if (!QueryInformationJobObject(job.value, JobObjectBasicAccountingInformation, &accounting, sizeof(accounting), nullptr)) break;
|
||||
if (accounting.ActiveProcesses == 0) { quiescent = true; break; }
|
||||
Sleep(10);
|
||||
}
|
||||
if (!quiescent) { status = "error"; exit = 125; }
|
||||
CancelSynchronousIo(controlThread.native_handle());
|
||||
controlThread.join();
|
||||
stdoutThread.join(); stderrThread.join();
|
||||
const bool cleaned = profile.remove();
|
||||
const bool unmapped = drive.release();
|
||||
const auto elapsed = GetTickCount64() - started;
|
||||
const std::string json = "{\"protocol\":1,\"backend\":\"windows-appcontainer\",\"isolated\":true,\"network\":\"deny\",\"status\":\"" +
|
||||
std::string(status) + "\",\"exitCode\":" + std::to_string(exit) + ",\"durationMs\":" + std::to_string(elapsed) +
|
||||
",\"profileRemoved\":" + (cleaned ? "true" : "false") + ",\"driveRemoved\":" + (unmapped ? "true" : "false") + ",\"broker\":" + (broker ? "\"bound-stdio-v1\"" : "null") + "}\n";
|
||||
writeJson(report, json);
|
||||
return 0;
|
||||
}
|
||||
#include "filesystem-read.h"
|
||||
#include "filesystem-write.h"
|
||||
#include "workspace-state.h"
|
||||
#include "git-control.h"
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
try {
|
||||
if (_wcsicmp(fs::path(argv[0]).filename().c_str(), L"ycode-git-editor.exe") == 0) return 0;
|
||||
if (_wcsicmp(fs::path(argv[0]).filename().c_str(), L"git-ssh.exe") == 0) return git_control::ssh(argc, argv);
|
||||
if (argc == 2 && (std::wstring(argv[1]) == L"--git-protect" || std::wstring(argv[1]) == L"--git-unprotect")) return git_control::credential(std::wstring(argv[1]) == L"--git-protect");
|
||||
if (argc == 4 && std::wstring(argv[1]) == L"--git-control") {
|
||||
size_t end = 0; const auto timeout = std::stoul(argv[3], &end);
|
||||
if (end != wcslen(argv[3]) || timeout > 600000) throw std::runtime_error("Invalid Git control timeout");
|
||||
return git_control::run(argv[2], static_cast<DWORD>(timeout));
|
||||
}
|
||||
if (argc == 3 && std::wstring(argv[1]) == L"--seal-workspace") return workspace_state::run(argv[2]);
|
||||
if (argc == 4 && (std::wstring(argv[1]) == L"--workspace-write" || std::wstring(argv[1]) == L"--git-metadata-write")) {
|
||||
const std::wstring expected(argv[3]);
|
||||
if (expected.find_first_not_of(L"0123456789abcdef:-") != std::wstring::npos) throw std::runtime_error("Invalid workspace identity");
|
||||
std::string expectedBytes;
|
||||
for (wchar_t ch : expected) expectedBytes += static_cast<char>(ch);
|
||||
return workspace_write::run(argv[2], expectedBytes, std::wstring(argv[1]) == L"--git-metadata-write");
|
||||
}
|
||||
if (argc == 7 && std::wstring(argv[1]) == L"--workspace-read") {
|
||||
const std::wstring expected(argv[5]);
|
||||
if (expected.find_first_not_of(L"0123456789abcdef:-") != std::wstring::npos) throw std::runtime_error("Invalid workspace identity");
|
||||
size_t end = 0; const auto limit = std::stoul(argv[6], &end);
|
||||
if (end != wcslen(argv[6]) || limit > 8 * 1024 * 1024) throw std::runtime_error("Invalid workspace limit");
|
||||
std::string expectedBytes;
|
||||
for (wchar_t ch : expected) expectedBytes += static_cast<char>(ch);
|
||||
return workspace_read::run(argv[2], argv[3], argv[4], expectedBytes, static_cast<DWORD>(limit));
|
||||
}
|
||||
if (argc == 2 && std::wstring(argv[1]) == L"--version") {
|
||||
std::puts("{\"protocol\":1,\"backend\":\"windows-appcontainer\",\"version\":\"0.7.0\",\"network\":\"deny\"}"); return 0;
|
||||
}
|
||||
if (argc == 3 && std::wstring(argv[1]) == L"--cleanup") {
|
||||
if (inContainer(GetCurrentProcess()) || !validProfile(argv[2])) throw std::runtime_error("Invalid profile cleanup request");
|
||||
const HRESULT cleaned = DeleteAppContainerProfile(argv[2]);
|
||||
return SUCCEEDED(cleaned) || cleaned == HRESULT_FROM_WIN32(ERROR_NOT_FOUND) || cleaned == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || cleaned == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND) ? 0 : 125;
|
||||
}
|
||||
if (argc == 4 && std::wstring(argv[1]) == L"--release-drive") {
|
||||
const std::wstring drive = argv[2];
|
||||
const fs::path stage(argv[3]);
|
||||
if (inContainer(GetCurrentProcess()) || drive.size() != 2 || drive[0] < L'D' || drive[0] > L'Z' || drive[1] != L':' || !stage.is_absolute() || stage.filename() != L"stage") throw std::runtime_error("Invalid drive cleanup request");
|
||||
const auto target = L"\\??\\" + stage.wstring();
|
||||
const BOOL removed = DefineDosDeviceW(DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE | DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, drive.c_str(), target.c_str());
|
||||
return removed || GetLastError() == ERROR_FILE_NOT_FOUND ? 0 : 125;
|
||||
}
|
||||
const std::wstring mode = argc > 1 ? argv[1] : L"";
|
||||
if (argc != 6 || (mode != L"--run" && mode != L"--run-stdio")) throw std::runtime_error("Usage: --run or --run-stdio profile stage result.json timeout-ms");
|
||||
size_t end = 0; const auto timeout = std::stoul(argv[5], &end);
|
||||
if (end != wcslen(argv[5]) || timeout < 100 || timeout > 600000) throw std::runtime_error("Invalid timeout");
|
||||
return run(argv[2], fs::path(argv[3]), fs::path(argv[4]), static_cast<DWORD>(timeout), mode == L"--run-stdio");
|
||||
} catch (const std::exception& error) { std::fprintf(stderr, "YCODE_SANDBOX_ERROR %s\n", error.what()); return 125; }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
namespace workspace_state {
|
||||
struct PrivateDacl {
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
PACL acl = nullptr;
|
||||
PrivateDacl() {
|
||||
Handle token;
|
||||
require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) != FALSE, "Open workspace seal token");
|
||||
DWORD bytes = 0; GetTokenInformation(token.value, TokenUser, nullptr, 0, &bytes);
|
||||
std::vector<BYTE> user(bytes);
|
||||
require(GetTokenInformation(token.value, TokenUser, user.data(), bytes, &bytes) != FALSE, "Read workspace seal owner");
|
||||
LPWSTR sid = nullptr;
|
||||
require(ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid) != FALSE, "Encode workspace seal owner");
|
||||
const std::wstring sddl = L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;" + std::wstring(sid) + L")";
|
||||
LocalFree(sid);
|
||||
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr) != FALSE, "Build workspace private DACL");
|
||||
BOOL present = FALSE, defaulted = FALSE;
|
||||
require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) != FALSE && present && acl, "Read workspace private DACL");
|
||||
}
|
||||
~PrivateDacl() { if (descriptor) LocalFree(descriptor); }
|
||||
void apply(HANDLE handle) const {
|
||||
security(SetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nullptr, nullptr, acl, nullptr), "Seal workspace handle DACL");
|
||||
}
|
||||
};
|
||||
static void seal(HANDLE handle, const PrivateDacl& dacl, size_t& count, unsigned int depth = 0) {
|
||||
const auto info = workspace_read::information(handle);
|
||||
if (++count > 10001 || depth > 64) throw std::runtime_error("Workspace seal entry or nesting limit");
|
||||
dacl.apply(handle);
|
||||
if (!(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) return;
|
||||
std::vector<BYTE> buffer(65536);
|
||||
for (;;) {
|
||||
if (!GetFileInformationByHandleEx(handle, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
|
||||
if (GetLastError() == ERROR_NO_MORE_FILES) break;
|
||||
require(false, "Enumerate workspace seal directory");
|
||||
}
|
||||
size_t offset = 0;
|
||||
for (;;) {
|
||||
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
|
||||
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid workspace seal directory record");
|
||||
const std::wstring name(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
|
||||
if (name != L"." && name != L"..") {
|
||||
if (!workspace_read::component(name) || entry->FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Workspace seal link or invalid name denied");
|
||||
Handle child(workspace_write::openRelative(handle, name, GENERIC_READ | READ_CONTROL | WRITE_DAC, false));
|
||||
require(child.value != INVALID_HANDLE_VALUE, "Open workspace seal child");
|
||||
seal(child.value, dacl, count, depth + 1);
|
||||
}
|
||||
if (entry->NextEntryOffset == 0) break;
|
||||
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid workspace seal directory offset");
|
||||
offset += entry->NextEntryOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
static int run(const std::wstring& path) {
|
||||
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace sealing is host-only");
|
||||
workspace_read::Lifetime lifetime(120000);
|
||||
workspace_write::Root root(path);
|
||||
if (root.held.size() < 2) throw std::runtime_error("A volume root cannot be a managed workspace");
|
||||
const auto names = workspace_read::parts(path.substr(3));
|
||||
Handle target(workspace_write::openRelative(root.held[root.held.size() - 2]->value, names.back(), GENERIC_READ | READ_CONTROL | WRITE_DAC, true));
|
||||
require(target.value != INVALID_HANDLE_VALUE, "Open workspace seal root");
|
||||
if (workspace_read::identity(target.value) != root.rootIdentity) throw std::runtime_error("Workspace seal root changed");
|
||||
PrivateDacl dacl; size_t count = 0;
|
||||
seal(target.value, dacl, count);
|
||||
workspace_write::reply("{\"protocol\":1,\"sealed\":true,\"rootIdentity\":\"" + root.rootIdentity + "\",\"entries\":" + std::to_string(count) + "}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Generated
+225
-1
@@ -8,6 +8,8 @@
|
||||
"name": "ycode",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
|
||||
"@anthropic-ai/sdk": "^0.135.0",
|
||||
"@floating-ui/react": "0.27.20",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
@@ -31,6 +33,7 @@
|
||||
"slate-react": "0.142.0",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -66,6 +69,168 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.304.tgz",
|
||||
"integrity": "sha512-Ivkt7/UAkATibzvqT31eY71lAp7YdPzfbKDpmbHN9+AQWFh/Ac6tYkL2J1sipJ7tU4IK48w0YxuRW9aI//veFA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.304",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.304"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.304.tgz",
|
||||
"integrity": "sha512-DMKedNHwWt1bFOINlYAebabk1ZH4DLvu59GIg6c/GFZjdpLQKn0IOVQzLGo28XJPMRQNVcZ3Hqbv5WeS63kF6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.304.tgz",
|
||||
"integrity": "sha512-n/OleF2p7HK4NgvJmmDXkbcOzooVJZfj4IGZKTR0Siljodvk9e0M69TTTJfJK5dph9cczOlqdXlgKeeV4TQ0lA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.304.tgz",
|
||||
"integrity": "sha512-xZtsRLVql1xtiAHhAX4l5ssMYN4r9hVW407+uazPajiVCKCvobOuQ2NAB7/QmKcRgiF18uWf1wluHT9gKZ0FJA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.304.tgz",
|
||||
"integrity": "sha512-cV6+7CzCL+m2DdjrQK771vHunGAOz/AKanPUHaEMqQF5riLL3UvK1/TD1VjaseDzQdzeD9F1qJm1aUP1TtyhzA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.304.tgz",
|
||||
"integrity": "sha512-72SZA+TYtrDCXM7j8GNaoUpZrtysxw8FiunRv6C59wJRkhpxzlYLGrjser7vX6ivK+LiBkVU/V3wzEyfJqB4sA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.304.tgz",
|
||||
"integrity": "sha512-cNZvVaVw5ly/sduYj+c2ekrqnpzqUBLsQapvpXhVFy6hjdwXhj8gCNmr5Wta5tlTrNEo0C2kxvfFm+GjD7Tcvg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.304.tgz",
|
||||
"integrity": "sha512-qzV7j4KP3QAWY5b1oOINNVoZzEFZy+Dbr4MBGoQpY2gycmyqEswhIP7SE0Q2meq8/WoVusLVNQ8irNwT/GRz9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.304",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.304.tgz",
|
||||
"integrity": "sha512-3vzbMxaG//37gbuViXx46B5OKpIaPGxESQgELAqtoxLRR/4iy+ANjSQ1HeuCpLkLSJzesoQ2Ss3U5XSeHeCLCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.135.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@anthropic-ai/sdk/-/sdk-0.135.0.tgz",
|
||||
"integrity": "sha512-WWrdL57JhU5KoynpTjt9GXK6k7ud9nPw2uw/Trn+oBXh6s0KpjhAccWZm2Qxw08ARYG1UDQj5iC+JO42j1rt/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -397,6 +562,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1612,6 +1786,15 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.154.0.tgz",
|
||||
@@ -2180,6 +2363,12 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -5431,6 +5620,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
@@ -6368,6 +6563,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -8275,6 +8483,16 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stat-mode": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
|
||||
@@ -8567,6 +8785,12 @@
|
||||
"utf8-byte-length": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-dedent": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.3.0.tgz",
|
||||
@@ -9051,7 +9275,7 @@
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.6.7.tgz",
|
||||
"resolved": "https://mirrors.tencent.com/npm/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
||||
+28
-61
@@ -8,49 +8,16 @@
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:renderer": "electron-vite dev --rendererOnly",
|
||||
"build": "npm run sandbox:build && npm run typecheck && electron-vite build",
|
||||
"sandbox:build": "node scripts/build-sandbox.mjs",
|
||||
"sandbox:vendor-git": "node scripts/vendor-sandbox-git.mjs",
|
||||
"test:sandbox": "npm run sandbox:build && node scripts/build-sandbox.mjs --probe && esbuild scripts/sandbox-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-regression.mjs --log-level=warning && node .tmp/sandbox-regression.mjs",
|
||||
"test:sandbox:app": "npm run build && node scripts/verify-sandbox-app.cjs",
|
||||
"test:sandbox:policy": "esbuild scripts/sandbox-policy-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-policy-regression.mjs --log-level=warning && node .tmp/sandbox-policy-regression.mjs",
|
||||
"test:sandbox:disabled": "esbuild scripts/sandbox-disabled-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-disabled-regression.mjs --log-level=warning && node .tmp/sandbox-disabled-regression.mjs",
|
||||
"test:sandbox:toolchains": "npm run sandbox:build && esbuild scripts/sandbox-toolchain-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-toolchain-regression.mjs --log-level=warning && node .tmp/sandbox-toolchain-regression.mjs",
|
||||
"test:sandbox:feasibility": "node scripts/build-sandbox.mjs --probe && esbuild scripts/sandbox-feasibility.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-feasibility.mjs --log-level=warning && node .tmp/sandbox-feasibility.mjs",
|
||||
"test:sandbox:directories": "esbuild scripts/sandbox-directory-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-directory-regression.mjs --log-level=warning && node .tmp/sandbox-directory-regression.mjs",
|
||||
"test:sandbox:write": "npm run sandbox:build && esbuild scripts/sandbox-write-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-write-regression.mjs --log-level=warning && node .tmp/sandbox-write-regression.mjs",
|
||||
"test:sandbox:workspace": "npm run sandbox:build && esbuild scripts/sandbox-workspace-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-workspace-regression.mjs --log-level=warning && node .tmp/sandbox-workspace-regression.mjs",
|
||||
"test:sandbox:stdio": "npm run sandbox:build && node scripts/build-sandbox.mjs --probe && esbuild scripts/sandbox-stdio-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-stdio-regression.mjs --log-level=warning && node .tmp/sandbox-stdio-regression.mjs",
|
||||
"test:sandbox:read-bridge": "npm run sandbox:build && esbuild scripts/sandbox-read-bridge-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-read-bridge-regression.mjs --log-level=warning && node .tmp/sandbox-read-bridge-regression.mjs",
|
||||
"test:sandbox:read-protocol": "esbuild scripts/sandbox-read-protocol-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-read-protocol-regression.mjs --log-level=warning && node .tmp/sandbox-read-protocol-regression.mjs",
|
||||
"test:sandbox:process": "npm run sandbox:build && esbuild scripts/sandbox-process-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-process-regression.mjs --log-level=warning && node .tmp/sandbox-process-regression.mjs",
|
||||
"test:sandbox:git": "npm run sandbox:build && esbuild scripts/sandbox-git-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-git-regression.mjs --log-level=warning && node .tmp/sandbox-git-regression.mjs",
|
||||
"test:sandbox:git:prepare": "npm ci --prefix scripts/fixtures/git-transport --ignore-scripts --no-audit --no-fund",
|
||||
"test:sandbox:git:complete": "npm run test:sandbox:git:prepare && npm run build && esbuild scripts/sandbox-git-complete-regression.ts scripts/sandbox-git-safety-regression.ts scripts/sandbox-git-remote-regression.ts --bundle --platform=node --format=esm --outdir=.tmp/git-complete-tests --out-extension:.js=.mjs --log-level=warning && node .tmp/git-complete-tests/sandbox-git-complete-regression.mjs && node .tmp/git-complete-tests/sandbox-git-safety-regression.mjs && node .tmp/git-complete-tests/sandbox-git-remote-regression.mjs && node scripts/verify-sandbox-app.cjs",
|
||||
"test:sandbox:bundled-git": "npm run sandbox:build && esbuild scripts/sandbox-bundled-git-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/sandbox-bundled-git-regression.mjs --log-level=warning && node .tmp/sandbox-bundled-git-regression.mjs",
|
||||
"sandbox:maintain": "node scripts/sandbox-maintain.mjs",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"check:tokens": "node scripts/check-design-tokens.mjs",
|
||||
"test:settings-performance": "node scripts/verify-settings-performance.cjs",
|
||||
"models:sync-metadata": "node scripts/sync-model-metadata.mjs",
|
||||
"test:custom-models": "esbuild scripts/custom-model-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/custom-model-regression.mjs --log-level=warning && node .tmp/custom-model-regression.mjs",
|
||||
"test:custom-model-native": "esbuild scripts/custom-model-native-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/custom-model-native-regression.mjs --log-level=warning && node .tmp/custom-model-native-regression.mjs",
|
||||
"test:model-metadata": "esbuild scripts/model-metadata-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/model-metadata-regression.mjs --log-level=warning && node .tmp/model-metadata-regression.mjs",
|
||||
"test:custom-model-settings": "node scripts/verify-custom-model-settings.cjs",
|
||||
"smoke": "esbuild scripts/smoke.ts --bundle --platform=node --format=esm --outfile=.tmp/smoke.mjs --log-level=warning && node .tmp/smoke.mjs",
|
||||
"test:omp": "esbuild scripts/omp-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/omp-regression.mjs --log-level=warning && node .tmp/omp-regression.mjs",
|
||||
"test:omp-session": "esbuild scripts/omp-session-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/omp-session-regression.mjs --log-level=warning && node .tmp/omp-session-regression.mjs",
|
||||
"test:chat": "esbuild scripts/message-order-regression.ts scripts/chat-store-regression.ts scripts/chat-stream-committer-regression.ts scripts/tool-output-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/chat-tests --log-level=warning && node .tmp/chat-tests/message-order-regression.mjs && node .tmp/chat-tests/chat-store-regression.mjs && node .tmp/chat-tests/chat-stream-committer-regression.mjs && node .tmp/chat-tests/tool-output-regression.mjs",
|
||||
"test:context-usage": "esbuild scripts/context-usage-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/context-usage-regression.mjs --log-level=error && node .tmp/context-usage-regression.mjs",
|
||||
"test:browser-automation": "node scripts/browser-automation-regression.cjs",
|
||||
"test:subagents": "esbuild scripts/subagent-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/subagent-regression.mjs --log-level=warning && node .tmp/subagent-regression.mjs",
|
||||
"test:subagents:app": "node scripts/subagent-app-regression.cjs",
|
||||
"test:subagents:display": "esbuild scripts/subagent-display-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/subagent-display-regression.mjs --log-level=warning && node .tmp/subagent-display-regression.mjs",
|
||||
"test:subagents:ui": "node scripts/verify-subagent-display.cjs",
|
||||
"test:context-observer": "esbuild scripts/context-observer-transport-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/context-observer-transport-regression.mjs --log-level=warning && node .tmp/context-observer-transport-regression.mjs",
|
||||
"test:claude-kernel": "esbuild scripts/claude-endpoint-regression.ts scripts/claude-tool-input-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/claude-tests --log-level=warning && node .tmp/claude-tests/claude-endpoint-regression.mjs && node .tmp/claude-tests/claude-tool-input-regression.mjs && node scripts/build-claude-kernel-regression.mjs && node .tmp/claude-kernel-integration.mjs && node scripts/claude-kernel-smoke.mjs",
|
||||
"test:chat": "esbuild scripts/message-order-regression.ts scripts/chat-store-regression.ts scripts/chat-stream-committer-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/chat-tests --log-level=warning && node .tmp/chat-tests/message-order-regression.mjs && node .tmp/chat-tests/chat-store-regression.mjs && node .tmp/chat-tests/chat-stream-committer-regression.mjs",
|
||||
"test:references": "esbuild scripts/reference-content-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/reference-content-regression.cjs --log-level=error && node .tmp/reference-content-regression.cjs",
|
||||
"test:expert-usage": "esbuild scripts/expert-usage-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/expert-usage-regression.mjs --log-level=warning && node .tmp/expert-usage-regression.mjs",
|
||||
"test:markdown": "esbuild scripts/markdown-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/markdown-regression.cjs --log-level=warning && node .tmp/markdown-regression.cjs",
|
||||
"test:recovered": "esbuild scripts/recovered-parser-regression.ts scripts/source-projection-regression.ts scripts/code-block-regression.tsx scripts/incremental-markdown-regression.ts scripts/math-renderer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/recovered-tests --log-level=warning && node .tmp/recovered-tests/recovered-parser-regression.cjs && node .tmp/recovered-tests/source-projection-regression.cjs && node .tmp/recovered-tests/code-block-regression.cjs && node .tmp/recovered-tests/incremental-markdown-regression.cjs && node .tmp/recovered-tests/math-renderer-regression.cjs",
|
||||
"recover:chat-reference": "node scripts/rea/recover-chat-reference.mjs",
|
||||
@@ -58,35 +25,27 @@
|
||||
"test:sessions": "esbuild scripts/session-store-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/session-store-regression.mjs --log-level=warning && node .tmp/session-store-regression.mjs",
|
||||
"shot": "node scripts/shot.mjs",
|
||||
"icon:build": "node scripts/gen-icon.mjs",
|
||||
"dist": "npm run omp:vendor && npm run build && electron-builder --win dir",
|
||||
"dist:installer": "npm run omp:vendor && npm run build && electron-builder --win nsis",
|
||||
"dist": "npm run build && electron-builder --win dir",
|
||||
"dist:installer": "npm run build && electron-builder --win nsis",
|
||||
"verify:ui": "node scripts/verify-ui.mjs",
|
||||
"wb:capture": "node scripts/wb-capture.mjs 9223 .tmp/wb-capture",
|
||||
"test:media": "esbuild scripts/mermaid-regression.ts --bundle --platform=node --format=esm --packages=external --jsx=automatic --loader:.css=empty --outfile=.tmp/media-tests/mermaid-regression.mjs --log-level=warning && node .tmp/media-tests/mermaid-regression.mjs && esbuild scripts/image-renderer-regression.tsx scripts/path-renderer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/media-tests --log-level=warning && node .tmp/media-tests/image-renderer-regression.cjs && node .tmp/media-tests/path-renderer-regression.cjs",
|
||||
"test:resources": "esbuild scripts/resource-bridge-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/resource-bridge-regression.mjs --log-level=warning && node .tmp/resource-bridge-regression.mjs",
|
||||
"recover:typography": "node scripts/rea/recover-typography-reference.mjs",
|
||||
"test:font-size": "esbuild scripts/font-size-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/font-size-regression.mjs --log-level=warning && node .tmp/font-size-regression.mjs",
|
||||
"test:conversation": "esbuild scripts/conversation-renderer-regression.tsx scripts/composer-regression.tsx scripts/conversation-runtime-regression.ts scripts/completed-fold-regression.tsx scripts/streaming-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=text --out-extension:.js=.cjs --outdir=.tmp/conversation-tests --log-level=error && node .tmp/conversation-tests/conversation-renderer-regression.cjs && node .tmp/conversation-tests/composer-regression.cjs && node .tmp/conversation-tests/conversation-runtime-regression.cjs && node .tmp/conversation-tests/streaming-footer-regression.cjs && node .tmp/conversation-tests/completed-fold-regression.cjs",
|
||||
"test:conversation": "esbuild scripts/conversation-renderer-regression.tsx scripts/composer-regression.tsx scripts/completed-fold-regression.tsx scripts/streaming-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=text --out-extension:.js=.cjs --outdir=.tmp/conversation-tests --log-level=error && node .tmp/conversation-tests/conversation-renderer-regression.cjs && node .tmp/conversation-tests/composer-regression.cjs && node .tmp/conversation-tests/streaming-footer-regression.cjs && node .tmp/conversation-tests/completed-fold-regression.cjs",
|
||||
"test:workbench-escape": "esbuild scripts/workbench-escape-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/workbench-escape-regression.mjs --log-level=warning && node .tmp/workbench-escape-regression.mjs",
|
||||
"test:assistant-footer": "esbuild scripts/assistant-message-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/assistant-message-footer-regression.cjs --log-level=error && node .tmp/assistant-message-footer-regression.cjs",
|
||||
"test:omp-protocol": "esbuild scripts/omp-protocol-probe.ts --bundle --platform=node --format=esm --outfile=.tmp/omp-protocol-probe.mjs --log-level=warning && node .tmp/omp-protocol-probe.mjs",
|
||||
"omp:vendor": "node scripts/vendor-omp.mjs",
|
||||
"omp:verify": "node scripts/vendor-omp.mjs --verify",
|
||||
"test:omp:bundled": "esbuild scripts/omp-bundled-path-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/omp-bundled-path-regression.mjs --log-level=warning && node .tmp/omp-bundled-path-regression.mjs && node scripts/verify-bundled-omp.cjs",
|
||||
"test:conversation-ipc": "esbuild scripts/conversation-ipc-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/conversation-ipc-regression.mjs --log-level=warning && node .tmp/conversation-ipc-regression.mjs",
|
||||
"test:personalization": "esbuild scripts/personalization-prompt-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/personalization-prompt-regression.mjs --log-level=warning && node .tmp/personalization-prompt-regression.mjs",
|
||||
"test:memory": "node scripts/verify-memory-settings.cjs",
|
||||
"test:remediation": "esbuild scripts/remediation-pure-regression.ts scripts/chat-completions-usage-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/remediation-tests --log-level=warning && node .tmp/remediation-tests/remediation-pure-regression.mjs && node .tmp/remediation-tests/chat-completions-usage-regression.mjs",
|
||||
"test:task-window": "node scripts/verify-task-window-stream.cjs",
|
||||
"test:remediation": "esbuild scripts/remediation-pure-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/remediation-pure-regression.mjs --log-level=warning && node .tmp/remediation-pure-regression.mjs",
|
||||
"test:archived-tasks": "node scripts/verify-archived-tasks.cjs",
|
||||
"test:task-running": "node scripts/verify-task-running-menu.cjs",
|
||||
"test:app-discover": "node scripts/verify-app-discover.cjs",
|
||||
"test:automations": "esbuild scripts/automation-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/automation-regression.mjs --log-level=warning && node .tmp/automation-regression.mjs",
|
||||
"test:library": "esbuild scripts/library-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/library-regression.mjs --log-level=warning && node .tmp/library-regression.mjs",
|
||||
"test:library-ui": "esbuild scripts/library-ui-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --loader:.svg=dataurl --outfile=.tmp/library-ui-regression.cjs --log-level=warning && node .tmp/library-ui-regression.cjs",
|
||||
"test:module-ui": "node scripts/verify-module-ui-parity.cjs",
|
||||
"test:project-tasks": "esbuild scripts/project-task-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/project-task-regression.mjs --log-level=warning && node .tmp/project-task-regression.mjs",
|
||||
"test:three-modules": "node scripts/verify-three-modules.cjs",
|
||||
"recover:artifact-reference": "node scripts/rea/recover-artifact-reference.mjs",
|
||||
"test:artifacts": "esbuild scripts/artifact-diff-regression.ts scripts/artifact-renderer-regression.tsx scripts/specialized-block-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/artifact-tests --log-level=warning && node .tmp/artifact-tests/artifact-diff-regression.cjs && node .tmp/artifact-tests/artifact-renderer-regression.cjs && node .tmp/artifact-tests/specialized-block-regression.cjs",
|
||||
"recover:conversation-labels": "node scripts/rea/recover-conversation-labels.mjs",
|
||||
@@ -116,6 +75,8 @@
|
||||
"test:rea-conversation": "node scripts/rea/selected-conversation-regression.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
|
||||
"@anthropic-ai/sdk": "^0.135.0",
|
||||
"@floating-ui/react": "0.27.20",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
@@ -139,6 +100,7 @@
|
||||
"slate-react": "0.142.0",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -169,26 +131,31 @@
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"!node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/**"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"asarUnpack": [
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/**"
|
||||
],
|
||||
"extraResources": [
|
||||
{ "from": "resources/model-metadata", "to": "model-metadata", "filter": ["models.dev.json", "manifest.json", "LICENSE"] },
|
||||
{
|
||||
"from": "resources/omp",
|
||||
"to": "omp",
|
||||
"filter": ["omp.exe", "LICENSE", "THIRD-PARTY-NOTICES.txt", "runtime.lock.json"]
|
||||
"from": "resources/model-metadata",
|
||||
"to": "model-metadata",
|
||||
"filter": [
|
||||
"models.dev.json",
|
||||
"manifest.json",
|
||||
"LICENSE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "resources/sandbox/win32-x64",
|
||||
"to": "sandbox/win32-x64",
|
||||
"from": "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64",
|
||||
"to": "claude",
|
||||
"filter": [
|
||||
"ycode-sandbox.exe",
|
||||
"node-bridge.cjs",
|
||||
"node-process.cjs",
|
||||
"python-sitecustomize.py",
|
||||
"git/**/*",
|
||||
"manifest.json"
|
||||
"claude.exe",
|
||||
"LICENSE.md",
|
||||
"README.md",
|
||||
"package.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"format": 1,
|
||||
"kernel": "omp",
|
||||
"upstream": "oh-my-pi",
|
||||
"version": "18.6.3",
|
||||
"platform": "win32-x64",
|
||||
"releaseUrl": "https://github.com/can1357/oh-my-pi/releases/tag/v18.6.3",
|
||||
"publishedAt": "2026-10-06T13:17:27Z",
|
||||
"binary": {
|
||||
"name": "omp.exe",
|
||||
"bytes": 235715072,
|
||||
"sha256": "453e8ecd17f36e0b7faba2abc761206fe72d16b97fbacbe1281831ad9fa86482"
|
||||
},
|
||||
"license": {
|
||||
"name": "LICENSE",
|
||||
"bytes": 1144,
|
||||
"sha256": "16c45f9d667442781f03fa198914cc39abcaa48ec5ed8f644643e554ca2fbf63"
|
||||
},
|
||||
"thirdPartyNotices": {
|
||||
"name": "THIRD-PARTY-NOTICES.txt",
|
||||
"bytes": 1076437,
|
||||
"sha256": "d0c2e7c05bb4d755044b13fa560be58d01ab7c980b87892400a979397e569a8b"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"format": 1,
|
||||
"provider": "git-for-windows",
|
||||
"version": "2.56.0.windows.2",
|
||||
"platform": "win32-x64",
|
||||
"runtimeManifestSha256": "17cbe59d1fdd9230e528be9ec6402b870637d5e7148215f278bbaff01b92ed01",
|
||||
"publishedAt": "2026-10-05T21:42:10Z",
|
||||
"releaseUrl": "https://github.com/git-for-windows/git/releases/tag/v2.56.0.windows.2",
|
||||
"archive": {
|
||||
"name": "MinGit-2.56.0.2-64-bit.zip",
|
||||
"url": "https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.2/MinGit-2.56.0.2-64-bit.zip",
|
||||
"bytes": 39806486,
|
||||
"sha256": "da35e72aa21c005a5a0d298cfbae110bc1609a815730ea0dde84b01a1b3cd3be"
|
||||
},
|
||||
"sources": {
|
||||
"git": "https://github.com/git-for-windows/git/tree/v2.56.0.windows.2",
|
||||
"gitSourceArchive": "https://github.com/git-for-windows/git/archive/refs/tags/v2.56.0.windows.2.tar.gz",
|
||||
"windowsBuild": "https://github.com/git-for-windows/build-extra",
|
||||
"dependencyBuildRecipes": "https://github.com/git-for-windows/MINGW-packages"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
'use strict'
|
||||
const fs = require('node:fs')
|
||||
const { randomUUID, createHash } = require('node:crypto')
|
||||
const prefix = '\x1eYCODE_WORKSPACE_READ_V1 '
|
||||
let selected = false
|
||||
function error(code, message) { return Object.assign(new Error(message), { code }) }
|
||||
function frame(operation, args, id) { return '\n' + prefix + JSON.stringify({ version: 1, requestId: id, operation, args }) + '\n' }
|
||||
function send(operation, args, id) {
|
||||
const bytes = Buffer.from(frame(operation, args, id))
|
||||
let offset = 0
|
||||
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset, bytes.length - offset)
|
||||
}
|
||||
function unpack(line, id) {
|
||||
let response
|
||||
try { response = JSON.parse(line) } catch { throw error('EPROTO', 'Invalid file bridge reply') }
|
||||
if (response.version !== 1 || response.requestId !== id || typeof response.ok !== 'boolean') throw error('EPROTO', 'Unexpected file bridge reply')
|
||||
if (!response.ok) throw error(response.error?.code || 'EACCES', response.error?.message || 'Workspace read denied')
|
||||
return response.value
|
||||
}
|
||||
function claim() {
|
||||
if (selected) throw error('EBUSY', 'Choose one bridge client and mode per process; stdin belongs to that client')
|
||||
selected = true
|
||||
}
|
||||
function decode(part, initial, offset) {
|
||||
if (!part || part.snapshotId !== initial.snapshotId || part.sha256 !== initial.sha256 || part.size !== initial.size || part.offset !== offset || typeof part.data !== 'string') throw error('EPROTO', 'Inconsistent read snapshot')
|
||||
const bytes = Buffer.from(part.data, 'base64')
|
||||
if (bytes.length > 16384 || !bytes.length && offset < initial.size || offset + bytes.length > initial.size || part.eof !== (offset + bytes.length === initial.size)) throw error('EPROTO', 'Invalid read chunk')
|
||||
return bytes
|
||||
}
|
||||
function complete(chunks, initial, encoding) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
if (bytes.length !== initial.size || createHash('sha256').update(bytes).digest('hex') !== initial.sha256) throw error('EPROTO', 'Read snapshot digest mismatch')
|
||||
return encoding ? bytes.toString(encoding) : bytes
|
||||
}
|
||||
function connectSync() {
|
||||
claim()
|
||||
let buffered = Buffer.alloc(0), closed = false
|
||||
function call(operation, args, id = randomUUID()) {
|
||||
if (closed) throw error('EPIPE', 'File bridge client closed')
|
||||
send(operation, args, id)
|
||||
for (;;) {
|
||||
const end = buffered.indexOf(10)
|
||||
if (end >= 0) {
|
||||
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
||||
return unpack(line, id)
|
||||
}
|
||||
if (buffered.length > 32768) throw error('EPROTO', 'File bridge reply exceeds limit')
|
||||
const bytes = Buffer.alloc(32768), length = fs.readSync(0, bytes, 0, bytes.length, null)
|
||||
if (!length) { closed = true; throw error('EPIPE', 'File bridge disconnected') }
|
||||
buffered = Buffer.concat([buffered, bytes.subarray(0, length)])
|
||||
}
|
||||
}
|
||||
return {
|
||||
call,
|
||||
readFileSync(path, encoding) {
|
||||
const initial = call('workspace.readFile', { path }), chunks = []
|
||||
try {
|
||||
let part = initial, offset = 0
|
||||
for (;;) {
|
||||
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
||||
if (part.eof) return complete(chunks, initial, encoding)
|
||||
part = call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
||||
}
|
||||
} finally { call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
||||
},
|
||||
statSync: path => call('workspace.stat', { path }),
|
||||
readdirSync: path => call('workspace.readdir', { path }).entries,
|
||||
querySync: requestId => call('operation.query', { requestId }),
|
||||
close() { closed = true }
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
claim()
|
||||
const waiting = new Map(), queue = []
|
||||
let buffered = Buffer.alloc(0), closed = false, failure
|
||||
function shutdown(reason) {
|
||||
if (closed) return
|
||||
closed = true; failure = reason
|
||||
process.stdin.off('data', receive); process.stdin.off('end', ended); process.stdin.off('error', failed); process.stdin.pause()
|
||||
process.stdin.destroy()
|
||||
for (const item of [...waiting.values(), ...queue]) item.reject(reason)
|
||||
waiting.clear(); queue.length = 0
|
||||
}
|
||||
function flush() {
|
||||
while (!closed && waiting.size < 8 && queue.length) {
|
||||
const item = queue.shift(); waiting.set(item.id, item)
|
||||
try { send(item.operation, item.args, item.id) } catch (reason) { shutdown(reason) }
|
||||
}
|
||||
if (waiting.size) { process.stdin.ref?.(); process.stdin.resume() }
|
||||
else { process.stdin.pause(); process.stdin.unref?.() }
|
||||
}
|
||||
function receive(bytes) {
|
||||
buffered = Buffer.concat([buffered, bytes])
|
||||
for (;;) {
|
||||
const end = buffered.indexOf(10)
|
||||
if (end < 0) { if (buffered.length > 32768) shutdown(error('EPROTO', 'File bridge reply exceeds limit')); return }
|
||||
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
||||
let id
|
||||
try { id = JSON.parse(line).requestId } catch { shutdown(error('EPROTO', 'Invalid file bridge reply')); return }
|
||||
const item = waiting.get(id)
|
||||
if (!item) { shutdown(error('EPROTO', 'Unexpected file bridge reply')); return }
|
||||
waiting.delete(id)
|
||||
try { item.resolve(unpack(line, id)) } catch (reason) { item.reject(reason) }
|
||||
flush()
|
||||
}
|
||||
}
|
||||
const ended = () => shutdown(error('EPIPE', 'File bridge disconnected'))
|
||||
const failed = reason => shutdown(reason)
|
||||
process.stdin.on('data', receive); process.stdin.on('end', ended); process.stdin.on('error', failed); process.stdin.pause()
|
||||
function call(operation, args, id = randomUUID()) {
|
||||
if (closed) return Promise.reject(failure || error('EPIPE', 'File bridge client closed'))
|
||||
if (queue.length + waiting.size >= 32) return Promise.reject(error('EBUSY', 'File bridge client queue full'))
|
||||
return new Promise((resolve, reject) => { queue.push({ id, operation, args, resolve, reject }); flush() })
|
||||
}
|
||||
return {
|
||||
call,
|
||||
async readFile(path, encoding) {
|
||||
const initial = await call('workspace.readFile', { path }), chunks = []
|
||||
try {
|
||||
let part = initial, offset = 0
|
||||
for (;;) {
|
||||
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
||||
if (part.eof) return complete(chunks, initial, encoding)
|
||||
part = await call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
||||
}
|
||||
} finally { await call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
||||
},
|
||||
stat: path => call('workspace.stat', { path }),
|
||||
readdir: async path => (await call('workspace.readdir', { path })).entries,
|
||||
query: requestId => call('operation.query', { requestId }),
|
||||
close() { shutdown(error('EPIPE', 'File bridge client closed')) }
|
||||
}
|
||||
}
|
||||
module.exports = { connect, connectSync }
|
||||
@@ -1,70 +0,0 @@
|
||||
'use strict'
|
||||
// Explicit preload for synchronous subprocess capture in Windows AppContainers.
|
||||
// Every subprocess still uses Node's own spawn and inherits the existing token/Job.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const cp = require('node:child_process')
|
||||
const { syncBuiltinESMExports } = require('node:module')
|
||||
const originals = { spawnSync: cp.spawnSync, execFileSync: cp.execFileSync, execSync: cp.execSync }
|
||||
const maximum = 16 * 1024 * 1024
|
||||
function captured(method, file, args, options, spawn) {
|
||||
if (options.stdio !== undefined && options.stdio !== 'pipe') return method()
|
||||
const limit = options.maxBuffer === undefined ? 1024 * 1024 : options.maxBuffer
|
||||
if (!Number.isSafeInteger(limit) || limit < 0 || limit > maximum) throw new RangeError('Sandbox process adapter maxBuffer must be between 0 and 16 MiB')
|
||||
const input = options.input === undefined ? Buffer.alloc(0) : typeof options.input === 'string'
|
||||
? Buffer.from(options.input, options.encoding && options.encoding !== 'buffer' ? options.encoding : 'utf8')
|
||||
: ArrayBuffer.isView(options.input) ? Buffer.from(options.input.buffer, options.input.byteOffset, options.input.byteLength) : null
|
||||
if (!input) throw new TypeError('Sandbox process adapter input must be a string, Buffer or ArrayBuffer view')
|
||||
if (input.length > maximum) throw new RangeError('Sandbox process adapter input exceeds 16 MiB')
|
||||
const directory = fs.mkdtempSync(path.join(process.env.TEMP, 'ycode-process-'))
|
||||
const files = ['stdin', 'stdout', 'stderr'].map(name => path.join(directory, name)), handles = []
|
||||
const encode = bytes => options.encoding && options.encoding !== 'buffer' ? bytes.toString(options.encoding) : bytes
|
||||
let answer, failure
|
||||
try {
|
||||
fs.writeFileSync(files[0], input, { flag: 'wx', mode: 0o600 })
|
||||
handles.push(fs.openSync(files[0], 'r'))
|
||||
handles.push(fs.openSync(files[1], 'wx+'))
|
||||
handles.push(fs.openSync(files[2], 'wx+'))
|
||||
const forwarded = { ...options, stdio: handles }; delete forwarded.input
|
||||
try { answer = method(forwarded) } catch (error) { failure = error }
|
||||
const read = index => {
|
||||
const buffer = Buffer.alloc(Math.min(fs.fstatSync(handles[index]).size, limit + 1))
|
||||
let length = 0
|
||||
while (length < buffer.length) { const count = fs.readSync(handles[index], buffer, length, buffer.length - length, length); if (!count) break; length += count }
|
||||
return { bytes: buffer.subarray(0, Math.min(length, limit)), overflow: length > limit }
|
||||
}
|
||||
const out = read(1), err = read(2), stdout = encode(out.bytes), stderr = encode(err.bytes)
|
||||
if (out.overflow || err.overflow) failure = Object.assign(new Error('Sandbox subprocess output exceeds maxBuffer'), { code: 'ENOBUFS', syscall: 'spawnSync', path: file, spawnargs: args })
|
||||
if (spawn) {
|
||||
if (failure && !answer) throw failure
|
||||
return { ...answer, stdout, stderr, output: [null, stdout, stderr], ...(failure ? { error: failure } : {}) }
|
||||
}
|
||||
if (failure) {
|
||||
failure.stdout = stdout; failure.stderr = stderr; failure.output = [null, stdout, stderr]
|
||||
throw failure
|
||||
}
|
||||
return stdout
|
||||
} finally {
|
||||
for (const handle of handles) try { fs.closeSync(handle) } catch {}
|
||||
for (const file of files) try { fs.unlinkSync(file) } catch {}
|
||||
try { fs.rmdirSync(directory) } catch {}
|
||||
}
|
||||
}
|
||||
function argumentsOf(args, options) {
|
||||
return Array.isArray(args) ? { args, options: options || {} } : { args: [], options: args == null ? options || {} : args }
|
||||
}
|
||||
function spawnSync(file, args, options) {
|
||||
const normalized = argumentsOf(args, options)
|
||||
return captured(forwarded => originals.spawnSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, true)
|
||||
}
|
||||
function execFileSync(file, args, options) {
|
||||
const normalized = argumentsOf(args, options)
|
||||
return captured(forwarded => originals.execFileSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, false)
|
||||
}
|
||||
function execSync(command, options = {}) {
|
||||
return captured(forwarded => originals.execSync(command, forwarded || options), command, [], options, false)
|
||||
}
|
||||
if (process.env.YCODE_SANDBOX !== 'appcontainer') throw new Error('The subprocess adapter is for the isolated YCode Node runtime')
|
||||
Object.assign(cp, { spawnSync, execFileSync, execSync })
|
||||
syncBuiltinESMExports()
|
||||
module.exports = { spawnSync, execFileSync, execSync }
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Keep private Windows temporary directories accessible to this AppContainer.
|
||||
|
||||
Python 3.13+ gives mode 0o700 an explicit user/admin DACL on Windows. That
|
||||
omits the AppContainer SID. Inside our stage, inherit the native private parent
|
||||
DACL instead. Kernel permissions and the Job remain the security boundary.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.name == "nt" and os.environ.get("YCODE_SANDBOX") == "appcontainer":
|
||||
_mkdir = os.mkdir
|
||||
_stage = os.path.normcase(os.path.abspath(os.environ["YCODE_SANDBOX_ROOT"]))
|
||||
|
||||
def _private_mkdir(path, mode=0o777, *, dir_fd=None):
|
||||
if mode == 0o700 and dir_fd is None:
|
||||
target = os.path.normcase(os.path.abspath(os.fsdecode(path)))
|
||||
try:
|
||||
if os.path.commonpath((_stage, target)) == _stage:
|
||||
mode = 0o777
|
||||
except ValueError:
|
||||
pass
|
||||
return _mkdir(path, mode, dir_fd=dir_fd)
|
||||
|
||||
os.mkdir = _private_mkdir
|
||||
@@ -1,256 +0,0 @@
|
||||
// Real hidden Electron Chromium + host tools + HTTP gateway and installed OMP, all against loopback fixtures.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/browser-automation-'))
|
||||
const modulePath = path.join(directory, 'host.cjs')
|
||||
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
|
||||
"export * from './src/main/browser-automation';", "export * from './src/main/agent/tools';",
|
||||
"export * from './src/main/agent/tools/browser';", "export * from './src/main/agent/conversation-tools';",
|
||||
"export * from './src/main/agent/loop';", "export * from './src/main/agent/omp-turn';",
|
||||
"export * from './src/main/agent/history';", "export * from './src/shared/message-parts';",
|
||||
"export * from './src/main/gateway';", "export * from './src/main/gateway/responses';",
|
||||
"export * from './src/main/gateway/native-protocols';", "export * from './src/main/omp';", "export * from './src/main/omp-model';"
|
||||
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: modulePath, logLevel: 'warning' })
|
||||
const env = { ...process.env, YCODE_BROWSER_TEST_DIRECTORY: directory, YCODE_OMP_HOME: path.join(directory, 'omp-home'),
|
||||
YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename],
|
||||
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', bytes => { output += bytes; process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
const report = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8').catch(() => '{"result":"MISSING"}'))
|
||||
if (code || report.result !== 'PASS') console.error(output.slice(-6000))
|
||||
console.log(JSON.stringify({ directory, exitCode: code, result: report.result })); process.exitCode = code || report.result !== 'PASS' ? 1 : 0
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const assert = require('node:assert/strict')
|
||||
const http = require('node:http')
|
||||
const directory = process.env.YCODE_BROWSER_TEST_DIRECTORY
|
||||
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
|
||||
app.on('window-all-closed', () => {})
|
||||
const servers = [], checks = [], apiRequests = []
|
||||
let host, kernel, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
function check(label, condition) { assert.ok(condition, label); checks.push(label); console.log('BROWSER_CHECK ' + label) }
|
||||
async function listen(handler) {
|
||||
const server = http.createServer(handler); servers.push(server)
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
return { server, base: 'http://127.0.0.1:' + server.address().port }
|
||||
}
|
||||
function page(next = false) {
|
||||
return `<!doctype html><html><head><title>${next ? 'Next fixture' : 'Browser fixture'}</title></head>
|
||||
<body style="background:#edf9f2;font:20px Arial;margin:32px;min-height:2400px">
|
||||
<h1>${next ? 'NEXT_PAGE' : 'BROWSER_FIXTURE'}</h1><label for="query">Query</label><input id="query" value="old value">
|
||||
<button id="apply">Apply</button><button id="later">Delayed status</button>
|
||||
<output id="result">Waiting</output><a id="next" href="/next">Next page</a>
|
||||
<div id="cookies"></div><div id="node-access"></div><input type="password" value="never-expose-password">
|
||||
<script>document.querySelector('#apply').addEventListener('click',event=>{document.querySelector('#result').textContent='APPLIED:'+document.querySelector('#query').value+':trusted='+event.isTrusted});
|
||||
document.querySelector('#later').onclick=()=>setTimeout(()=>document.querySelector('#result').textContent='DELAYED_READY',150);
|
||||
document.querySelector('#cookies').textContent='cookie:'+document.cookie;
|
||||
document.querySelector('#node-access').textContent='node:'+typeof require+'/'+typeof process;</script></body></html>`
|
||||
}
|
||||
const ctx = (sessionId = 'browser-a', extra = {}) => ({ workspaceRoot: directory, sessionId, runId: 'test-' + sessionId,
|
||||
toolCallId: 'browser-test', permissionMode: 'full-access', signal: new AbortController().signal, ...extra })
|
||||
async function call(args, context = ctx()) { return host.executeTool('browser', args, context) }
|
||||
async function ok(args, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, true, result.content); return result }
|
||||
async function rejected(args, pattern, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, false); assert.match(result.content, pattern); return result }
|
||||
function sse(res, call, final) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: call.id, type: 'function', function: { name: 'browser', arguments: JSON.stringify(call.args) } }] }
|
||||
: { role: 'assistant', content: final }
|
||||
res.end([{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta, finish_reason: null }] },
|
||||
{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }]
|
||||
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
|
||||
}
|
||||
async function conversation(viaOmp, fixture, production = false, supportsImages = true) {
|
||||
const mode = production ? 'APP' : (viaOmp ? 'OMP' : 'HTTP') + (supportsImages ? '' : '_TEXT_ONLY')
|
||||
let stage = 0, observedImage = false, observedApplied = false
|
||||
const expected = mode + '_BROWSER_VALUE'
|
||||
const api = await listen(async (req, res) => {
|
||||
try {
|
||||
let source = ''; for await (const chunk of req) source += chunk
|
||||
const body = JSON.parse(source || '{}'); apiRequests.push({ mode, path: req.url, body })
|
||||
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
|
||||
assert.ok(body.tools.some(tool => tool.function.name === 'browser'), 'model actually receives browser tool')
|
||||
const serialized = JSON.stringify(body.messages)
|
||||
observedApplied ||= serialized.includes('APPLIED:' + expected + ':trusted=true')
|
||||
observedImage ||= serialized.includes('data:image/jpeg;base64,')
|
||||
const actions = [{ action: 'open', url: fixture + '/page' }, { action: 'type', selector: '#query', text: expected },
|
||||
{ action: 'click', selector: '#apply' }, { action: 'screenshot' }]
|
||||
if (stage < actions.length) sse(res, { id: mode + '-call-' + stage, args: actions[stage++] })
|
||||
else { assert.ok(observedApplied, 'page changes returned to model'); assert.equal(observedImage, supportsImages, 'screenshot follows model image capabilities'); sse(res, undefined, mode + '_BROWSER_COMPLETE') }
|
||||
} catch (error) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } })) }
|
||||
})
|
||||
const events = [], common = { runId: mode + '-run', sessionId: mode + '-session', workspaceRoot: directory,
|
||||
userText: 'Use the browser to fill the fixture and inspect its screenshot.', signal: new AbortController().signal,
|
||||
permissionMode: 'full-access', supportsImages, emit: event => events.push(event) }
|
||||
const config = { baseUrl: api.base + '/v1', api: 'openai-completions', model: 'browser-controlled', apiKey: 'loopback-only', maxOutputTokens: 1024, maxToolRounds: 8 }
|
||||
let result
|
||||
if (production) {
|
||||
// The harness already disabled acceleration before app readiness.
|
||||
delete process.env.YCODE_DISABLE_GPU
|
||||
delete process.env.YCODE_DISABLE_GPU_SANDBOX
|
||||
await import(require('node:url').pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
let main
|
||||
for (let index = 0; index < 300; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(main, 'production main window')
|
||||
const read = source => main.webContents.executeJavaScript(source)
|
||||
for (let index = 0; index < 200 && !await read('!!window.ycode'); index++) await pause(25)
|
||||
await read('window.__browserEvents=[];window.ycode.chat.onEvent(event=>window.__browserEvents.push(event));true')
|
||||
const profile = { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled', protocol: 'openai',
|
||||
apiKey: 'loopback-only', baseUrl: config.baseUrl, endpoint: { api: 'openai-completions', baseUrl: config.baseUrl, checkedAt: Date.now() },
|
||||
supportsToolCall: true, supportsImages: true, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 }
|
||||
await read(`window.ycode.models.save(${JSON.stringify(profile)})`)
|
||||
const workspace = await read(`window.ycode.workspace.attach(${JSON.stringify(directory)})`)
|
||||
const session = await read(`window.ycode.session.create('Browser APP',{modelId:'browser-controlled',workspaceId:${JSON.stringify(workspace.attachedWorkspaceId)}})`)
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Control the browser fixture.',permissionMode:'full-access'})`)
|
||||
let assistant
|
||||
for (let index = 0; index < 1200; index++) {
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`)
|
||||
assistant = messages.filter(message => message.role === 'assistant').at(-1)
|
||||
if (assistant && ['done', 'error', 'cancelled'].includes(assistant.status) && !await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`)) break
|
||||
await pause(40)
|
||||
}
|
||||
assert.equal(assistant?.status, 'done', JSON.stringify({ status: assistant?.status, error: assistant?.error,
|
||||
tools: assistant?.tools?.map(tool => ({ name: tool.name, status: tool.status })) }))
|
||||
result = { content: assistant.content, tools: assistant.tools }
|
||||
events.push(...await read('window.__browserEvents'))
|
||||
check('APP: production IPC persists the screenshot as an assistant image part', assistant.parts.some(part => part.type === 'image' && part.url.startsWith('data:image/jpeg;base64,')))
|
||||
const persisted = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
|
||||
check('APP: cold history contains successful browser actions and the real screenshot', persisted.sessions.some(item => item.id === session.id && item.messages.some(message => message.id === assistant.id && message.parts?.some(part => part.type === 'image'))))
|
||||
check('APP: the production host creates a hidden browser through its own registration', BrowserWindow.getAllWindows().some(window => window !== main && !window.isVisible()))
|
||||
await read(`window.ycode.session.remove(${JSON.stringify(session.id)})`)
|
||||
check('APP: deleting a session disposes its browser and native kernel', BrowserWindow.getAllWindows().length === 1)
|
||||
} else if (viaOmp) {
|
||||
const executable = host.detectOmpPath(); assert.ok(executable, 'installed OMP is available')
|
||||
const prepared = await host.prepareOmpModel({ config, profile: { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled',
|
||||
baseUrl: config.baseUrl, hasApiKey: true, keyStorage: 'none', supportsToolCall: true, supportsImages, supportsReasoning: false,
|
||||
contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 } })
|
||||
kernel = new host.OmpKernel(executable, directory, prepared.profile, path.join(directory, 'omp-browser-session.jsonl'), prepared.model, prepared.env)
|
||||
result = await host.runOmpTurn({ ...common, kernel })
|
||||
await kernel.dispose(); kernel = undefined
|
||||
} else result = await host.runTurn({ ...common, history: [], gateway: new host.Gateway(config), model: config.model, maxToolRounds: 8 })
|
||||
check(mode + ': controlled model completes a real browser open/type/click/screenshot round trip', result.content.includes(mode + '_BROWSER_COMPLETE'))
|
||||
check(mode + ': tool events contain four successful browser actions', result.tools.filter(tool => tool.name === 'browser' && tool.status === 'ok').length === 4)
|
||||
check(mode + ': screenshot is visible in conversation image events', events.some(event => event.type === 'part' && event.part.type === 'image' && event.part.url.startsWith('data:image/jpeg;base64,')))
|
||||
check(mode + ': page content and image capability are respected', observedApplied && observedImage === supportsImages)
|
||||
const final = apiRequests.filter(request => request.mode === mode).at(-1)
|
||||
if (!viaOmp && supportsImages) {
|
||||
const wire = { messages: final.body.messages }
|
||||
check('Responses fallback keeps browser screenshots as input_image', JSON.stringify(host.buildResponsesRequest(config, wire)).includes('input_image'))
|
||||
check('Anthropic fallback keeps browser screenshots as image blocks', JSON.stringify(host.buildAnthropicRequest(config, wire)).includes('"type":"image"'))
|
||||
check('Gemini fallback keeps browser screenshots as inlineData', JSON.stringify(host.buildGeminiRequest(config, wire)).includes('inlineData'))
|
||||
}
|
||||
if (!production) {
|
||||
const message = events.reduce((message, event) => host.applyAssistantEvent(message, event),
|
||||
{ id: mode + '-assistant', requestId: common.runId, role: 'assistant', content: '', createdAt: 0 })
|
||||
check(mode + ': compatible history replays screenshot images in subsequent turns', JSON.stringify(host.assistantWireHistory(message)).includes('data:image/jpeg;base64,'))
|
||||
check(mode + ': text-only history excludes screenshot input without losing tool results', !JSON.stringify(host.assistantWireHistory(message, { supportsImages: false })).includes('data:image/jpeg;base64,') && host.assistantWireHistory(message, { supportsImages: false }).some(row => row.role === 'tool'))
|
||||
}
|
||||
host.disposeSessionBrowsers(common.sessionId)
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
host = require(path.join(directory, 'host.cjs')); host.configureBrowserTool(host.executeBrowserTool)
|
||||
const fixture = await listen((req, res) => {
|
||||
if (req.url === '/hang') return
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page(req.url === '/next'))
|
||||
})
|
||||
phase = 'direct-browser-actions'
|
||||
check('browser is registered in the host tool schema', host.toolSpecs().some(tool => tool.function.name === 'browser'))
|
||||
await rejected({ action: 'open', url: 'file:///C:/Windows/win.ini' }, /HTTP/)
|
||||
await rejected({ action: 'open', url: 'javascript:alert(1)' }, /HTTP/)
|
||||
await rejected({ action: 'open', url: 'http://user:password@localhost/' }, /HTTP/)
|
||||
check('file, script and credential URLs are refused', BrowserWindow.getAllWindows().length === 0)
|
||||
const blocked = await host.executeTurnTool('browser', { action: 'open', url: fixture.base + '/page' },
|
||||
{ ...ctx('mode-denied'), agentMode: 'plan', emit() {} })
|
||||
check('plan mode cannot bypass the shared tool gate', !blocked.ok && BrowserWindow.getAllWindows().length === 0)
|
||||
const hostBrowserContext = ctx('host-browser', { permissionMode: 'default-sandbox', approve: async () => false })
|
||||
const hostBrowser = await ok({ action: 'open', url: fixture.base + '/page' }, hostBrowserContext)
|
||||
check('host browser opens without sandbox approval', BrowserWindow.getAllWindows().length === 1)
|
||||
await ok({ action: 'close', tabId: hostBrowser.data.tabId }, hostBrowserContext)
|
||||
check('host browser close releases the unapproved tab', BrowserWindow.getAllWindows().length === 0)
|
||||
const first = await ok({ action: 'open', url: fixture.base + '/page' })
|
||||
const id = first.data.tabId
|
||||
check('real hidden Chromium returns the rendered page and interactive refs', first.data.text.includes('BROWSER_FIXTURE') && first.data.elements.some(element => element.name === 'Apply'))
|
||||
check('browser remains hidden and has no renderer Node access', BrowserWindow.getAllWindows().every(window => !window.isVisible()) && first.data.text.includes('node:undefined/undefined'))
|
||||
check('password values do not appear in browser snapshots', !first.content.includes('never-expose-password'))
|
||||
const managed = BrowserWindow.getAllWindows()[0], prefs = managed.webContents.getLastWebPreferences()
|
||||
check('browser renderer uses isolation and a sandbox without preload', prefs.sandbox && prefs.contextIsolation && !prefs.nodeIntegration && !prefs.preload)
|
||||
await rejected({ action: 'snapshot', tabId: id }, /不属于/, ctx('browser-b'))
|
||||
check('another conversation cannot use the browser id', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
|
||||
await managed.webContents.session.cookies.set({ url: fixture.base, name: 'isolation', value: 'private-a' })
|
||||
const second = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('browser-b'))
|
||||
check('browser cookies are isolated between conversations', !second.data.text.includes('private-a'))
|
||||
await rejected({ action: 'click', tabId: id, selector: 'button' }, /唯一匹配/)
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: '' })
|
||||
check('type can clear a text field', (await ok({ action: 'snapshot', tabId: id })).data.elements.find(element => element.tag === 'input' && element.type === 'text').value === '')
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: 'typed real text' })
|
||||
const applied = await ok({ action: 'click', tabId: id, ref: first.data.elements.find(element => element.name === 'Apply').ref })
|
||||
check('typing and clicking produce actual trusted Chromium events', applied.data.text.includes('APPLIED:typed real text:trusted=true'))
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: '+append', replace: false })
|
||||
const appended = await ok({ action: 'snapshot', tabId: id })
|
||||
check('type supports append without erasing existing input', appended.data.elements.find(element => element.tag === 'input' && element.type === 'text').value === 'typed real text+append')
|
||||
await ok({ action: 'press_key', tabId: id, key: 'Tab' })
|
||||
await ok({ action: 'press_key', tabId: id, key: 'Enter' })
|
||||
check('keyboard events can activate the focused button', (await ok({ action: 'snapshot', tabId: id })).data.text.includes('APPLIED:typed real text+append:trusted=true'))
|
||||
await ok({ action: 'click', tabId: id, selector: '#later' })
|
||||
await ok({ action: 'wait', tabId: id, text: 'DELAYED_READY', timeoutMs: 1500 })
|
||||
check('wait observes asynchronous rendered page changes', true)
|
||||
await rejected({ action: 'wait', tabId: id, text: 'never-present', timeoutMs: 100 }, /超时/)
|
||||
const scrolled = await ok({ action: 'scroll', tabId: id, y: 700 })
|
||||
check('scroll changes the real page viewport', scrolled.data.scroll.y > 0)
|
||||
await ok({ action: 'scroll', tabId: id, y: -700 })
|
||||
const capture = await ok({ action: 'screenshot', tabId: id })
|
||||
const bytes = Buffer.from(capture.images[0].data, 'base64'); await fs.writeFile(path.join(directory, 'browser-actual.jpg'), bytes)
|
||||
check('screenshot returns a bounded real JPEG image', bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.length > 2000 && bytes.length < 500 * 1024)
|
||||
check('taking a screenshot never opens a visible window', BrowserWindow.getAllWindows().every(window => !window.isVisible()))
|
||||
const oldRef = applied.data.elements.find(element => element.name === 'Apply').ref
|
||||
const next = await ok({ action: 'navigate', tabId: id, url: fixture.base + '/next' })
|
||||
check('navigate reads the new document', next.data.text.includes('NEXT_PAGE'))
|
||||
await rejected({ action: 'click', tabId: id, ref: oldRef }, /ref/)
|
||||
check('refs from previous documents cannot trigger new-page controls', true)
|
||||
let approvals = 0
|
||||
const noApproval = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('approved', { permissionMode: 'default-sandbox', approve: async () => { approvals++; return true } }))
|
||||
check('legacy sandbox approval callback is never invoked', approvals === 0)
|
||||
await ok({ action: 'close', tabId: noApproval.data.tabId }, ctx('approved'))
|
||||
const cancellation = new AbortController(), started = Date.now()
|
||||
setTimeout(() => cancellation.abort(), 150)
|
||||
await rejected({ action: 'open', url: fixture.base + '/hang' }, /取消/, ctx('cancelled-open', { signal: cancellation.signal }))
|
||||
check('cancelled navigation stops promptly without leaving an orphan tab', Date.now() - started < 2000 && (await ok({ action: 'list' }, ctx('cancelled-open'))).data.length === 0)
|
||||
host.disposeSessionBrowsers('browser-a')
|
||||
check('session cleanup closes only its own browsers', (await ok({ action: 'list' })).data.length === 0 && (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 1)
|
||||
await ok({ action: 'close', tabId: second.data.tabId }, ctx('browser-b'))
|
||||
check('close releases a browser through the public tool action', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
|
||||
host.disposeBrowserAutomation()
|
||||
check('all browser contents are released', BrowserWindow.getAllWindows().length === 0)
|
||||
phase = 'http-conversation'; await conversation(false, fixture.base)
|
||||
phase = 'omp-conversation'; await conversation(true, fixture.base)
|
||||
phase = 'text-only-http-conversation'; await conversation(false, fixture.base, false, false)
|
||||
phase = 'text-only-omp-conversation'; await conversation(true, fixture.base, false, false)
|
||||
phase = 'production-conversation'; await conversation(true, fixture.base, true)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, apiRequests, phase }, null, 2))
|
||||
console.log('BROWSER_AUTOMATION_PASS ' + directory)
|
||||
}).catch(async error => {
|
||||
console.error(error)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, checks, error: String(error.stack || error), apiRequests }, null, 2))
|
||||
process.exitCode = 1
|
||||
}).finally(async () => {
|
||||
if (kernel) await kernel.dispose().catch(() => undefined)
|
||||
host?.disposeBrowserAutomation()
|
||||
for (const server of servers) { server.closeAllConnections(); server.close() }
|
||||
await pause(100); app.exit(process.exitCode || 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['scripts/claude-kernel-integration.ts'],
|
||||
outfile: '.tmp/claude-kernel-integration.mjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
external: ['@anthropic-ai/claude-agent-sdk'],
|
||||
logLevel: 'warning',
|
||||
plugins: [{
|
||||
name: 'claude-kernel-electron-stub',
|
||||
setup(plugin) {
|
||||
plugin.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'claude-test' }))
|
||||
plugin.onLoad({ filter: /.*/, namespace: 'claude-test' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? process.env.CLAUDE_TEST_USER_DATA : process.cwd(),
|
||||
getAppPath: () => process.cwd()
|
||||
};
|
||||
`
|
||||
}))
|
||||
}
|
||||
}]
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { verifyBundledGit } from './vendor-sandbox-git.mjs'
|
||||
|
||||
export async function buildSandbox({ outputDirectory = path.resolve('resources/sandbox/win32-x64'), probe = false } = {}) {
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('The sandbox native build currently requires Windows x64')
|
||||
const bundledGit = !probe ? await verifyBundledGit(path.join(outputDirectory, 'git')).catch(error => { throw new Error(`Bundled Git is missing or invalid; run npm run sandbox:vendor-git. ${error.message}`) }) : undefined
|
||||
const programFiles = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
||||
const vswhere = path.join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe')
|
||||
const installation = execFileSync(vswhere, ['-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], { encoding: 'utf8', windowsHide: true }).trim()
|
||||
if (!installation) throw new Error('Visual Studio C++ Build Tools are required; see native/sandbox/README.md')
|
||||
const versions = async directory => (await fs.readdir(directory)).filter(name => /^\d+(?:\.\d+)+$/.test(name)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
const msvcRoot = path.join(installation, 'VC/Tools/MSVC')
|
||||
const msvc = path.join(msvcRoot, (await versions(msvcRoot))[0])
|
||||
const sdkRoot = path.join(programFiles, 'Windows Kits/10')
|
||||
const sdk = (await versions(path.join(sdkRoot, 'Include')))[0]
|
||||
if (!sdk) throw new Error('Windows 10/11 SDK is required')
|
||||
const compilerDirectory = path.join(msvc, 'bin/Hostx64/x64')
|
||||
const environment = { ...process.env,
|
||||
PATH: `${compilerDirectory};${process.env.PATH || ''}`,
|
||||
INCLUDE: [path.join(msvc, 'include'), ...['ucrt', 'shared', 'um', 'winrt'].map(part => path.join(sdkRoot, 'Include', sdk, part))].join(';'),
|
||||
LIB: [path.join(msvc, 'lib/x64'), ...['ucrt', 'um'].map(part => path.join(sdkRoot, 'Lib', sdk, part, 'x64'))].join(';') }
|
||||
await fs.mkdir(outputDirectory, { recursive: true })
|
||||
const objects = await fs.mkdtemp(path.resolve('.tmp/sandbox-native-build-'))
|
||||
const source = path.resolve(probe ? 'native/sandbox/probe.cpp' : 'native/sandbox/runner.cpp')
|
||||
const binary = path.join(outputDirectory, probe ? 'sandbox-probe.exe' : 'ycode-sandbox.exe')
|
||||
const args = ['/nologo', '/std:c++17', '/EHsc', '/W4', '/WX', '/MT', '/O2', '/Brepro', '/utf-8', '/DUNICODE', '/D_UNICODE', '/D_WIN32_WINNT=0x0A00', `/Fo${objects}${path.sep}`, `/Fe${binary}`, source, '/link', '/INCREMENTAL:NO', 'advapi32.lib', 'userenv.lib', 'ws2_32.lib', 'bcrypt.lib']
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(path.join(compilerDirectory, 'cl.exe'), args, { env: environment, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''
|
||||
child.stdout.on('data', bytes => { log += bytes })
|
||||
child.stderr.on('data', bytes => { log += bytes })
|
||||
child.once('error', reject)
|
||||
child.once('close', code => code === 0 ? resolve() : reject(new Error(`Sandbox compiler exited ${code}\n${log}`)))
|
||||
})
|
||||
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const manifest = { protocol: 1, backend: 'windows-appcontainer', version: '0.7.0', architecture: 'x64',
|
||||
sourceSha256: hash(await fs.readFile(source)), binarySha256: hash(await fs.readFile(binary)), compiler: path.basename(msvc), sdk }
|
||||
if (!probe) {
|
||||
manifest.workspaceTransactions = 1
|
||||
manifest.workspaceState = 1
|
||||
manifest.gitMetadataTransactions = 1
|
||||
manifest.bundledGit = bundledGit
|
||||
manifest.sources = Object.fromEntries(await Promise.all(['runner.cpp', 'filesystem-read.h', 'filesystem-write.h', 'workspace-state.h', 'git-control.h', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py'].map(async name => [name, hash(await fs.readFile(path.resolve('native/sandbox', name)))])))
|
||||
const adapter = await fs.readFile(path.resolve('native/sandbox/node-bridge.cjs'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'node-bridge.cjs'), adapter)
|
||||
manifest.nodeBridgeSha256 = hash(adapter)
|
||||
const processes = await fs.readFile(path.resolve('native/sandbox/node-process.cjs'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'node-process.cjs'), processes)
|
||||
manifest.nodeProcessSha256 = hash(processes)
|
||||
const pythonAdapter = await fs.readFile(path.resolve('native/sandbox/python-sitecustomize.py'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'python-sitecustomize.py'), pythonAdapter)
|
||||
manifest.pythonAdapterSha256 = hash(pythonAdapter)
|
||||
}
|
||||
if (!probe) await fs.writeFile(path.join(outputDirectory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
||||
console.log(`SANDBOX_BUILD_PASS ${path.relative(process.cwd(), binary)}`)
|
||||
return binary
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
|
||||
await buildSandbox({ probe: process.argv.includes('--probe') })
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { Gateway, GatewayError, type GatewayDelta } from '../src/main/gateway'
|
||||
|
||||
const requests = new Map<string, Array<Record<string, unknown>>>()
|
||||
const abortConnections = new Set<import('node:http').ServerResponse>()
|
||||
|
||||
function attempts(model: string): Array<Record<string, unknown>> {
|
||||
return requests.get(model) ?? []
|
||||
}
|
||||
|
||||
function recordAttempt(body: Record<string, unknown>): number {
|
||||
const model = String(body.model)
|
||||
const items = requests.get(model) ?? []
|
||||
items.push(body)
|
||||
requests.set(model, items)
|
||||
return items.length
|
||||
}
|
||||
|
||||
function successSse(response: import('node:http').ServerResponse, usage: Record<string, unknown>): void {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
{ choices: [{ delta: { content: 'ok' }, finish_reason: null }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
{ choices: [], usage }
|
||||
].map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join('') + 'data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk))
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString()) as Record<string, unknown>
|
||||
const model = String(body.model)
|
||||
const attempt = recordAttempt(body)
|
||||
const validUsage = {
|
||||
prompt_tokens: 128,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 130,
|
||||
prompt_tokens_details: { cached_tokens: 32 }
|
||||
}
|
||||
|
||||
if (model === 'valid' || model === 'invalid-usage') {
|
||||
successSse(response, model === 'invalid-usage'
|
||||
? { prompt_tokens: -1, completion_tokens: 2, total_tokens: 1 }
|
||||
: validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'legacy-400') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'Unsupported parameter: stream_options' } }))
|
||||
return
|
||||
}
|
||||
successSse(response, validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'legacy-422') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(422, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'include_usage is not supported by this server' } }))
|
||||
return
|
||||
}
|
||||
successSse(response, validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'ordinary-400') {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
error: { message: 'request validation failed while parsing stream_options' },
|
||||
apiKey: 'UPSTREAM_SECRET_MUST_NOT_ECHO'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'auth-401') {
|
||||
response.writeHead(401, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
error: { message: 'invalid api key' },
|
||||
authorization: 'Bearer UPSTREAM_SECRET_MUST_NOT_ECHO'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'server-500-options') {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'stream_options unsupported due to server failure' } }))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'started-sse-error') {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'before-error' }, finish_reason: null }] })}\n\n`)
|
||||
response.write(`data: ${JSON.stringify({ error: { message: 'stream_options unsupported' } })}\n\n`)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'abort-retry') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'unknown field: stream_options' } }))
|
||||
return
|
||||
}
|
||||
abortConnections.add(response)
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
response.flushHeaders()
|
||||
const keepalive = setInterval(() => {
|
||||
if (!response.destroyed) response.write(': keepalive\n\n')
|
||||
}, 100)
|
||||
response.on('close', () => {
|
||||
clearInterval(keepalive)
|
||||
abortConnections.delete(response)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: `unknown QA model: ${model}` } }))
|
||||
})
|
||||
|
||||
function gatewayFor(model: string): Gateway {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
return new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
apiKey: '',
|
||||
authMode: 'none',
|
||||
model,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
})
|
||||
}
|
||||
|
||||
async function collect(model: string, signal?: AbortSignal): Promise<GatewayDelta[]> {
|
||||
const events: GatewayDelta[] = []
|
||||
for await (const event of gatewayFor(model).stream({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
signal
|
||||
})) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
async function captureError(model: string): Promise<GatewayError> {
|
||||
try {
|
||||
await collect(model)
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof GatewayError, `${model} must reject with GatewayError`)
|
||||
return error
|
||||
}
|
||||
assert.fail(`${model} should reject`)
|
||||
}
|
||||
|
||||
async function waitForAttempts(model: string, count: number): Promise<void> {
|
||||
for (let index = 0; index < 250; index++) {
|
||||
if (attempts(model).length === count) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.fail(`${model} did not reach ${count} requests`)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
try {
|
||||
const valid = await collect('valid')
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'finish'), [{ type: 'finish', reason: 'stop' }])
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'usage'), [{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
|
||||
}])
|
||||
assert.equal(attempts('valid').length, 1)
|
||||
assert.deepEqual(attempts('valid')[0]?.stream_options, { include_usage: true })
|
||||
|
||||
const invalidUsage = await collect('invalid-usage')
|
||||
assert.equal(invalidUsage.filter((event) => event.type === 'usage').length, 0,
|
||||
'invalid usage must not emit a usage event')
|
||||
|
||||
for (const model of ['legacy-400', 'legacy-422']) {
|
||||
const events = await collect(model)
|
||||
assert.deepEqual(events.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
|
||||
assert.deepEqual(events.filter((event) => event.type === 'usage'), [{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
|
||||
}])
|
||||
const tries = attempts(model)
|
||||
assert.equal(tries.length, 2, `${model} must retry exactly once`)
|
||||
assert.deepEqual(tries[0]?.stream_options, { include_usage: true })
|
||||
assert.equal(Object.hasOwn(tries[1] ?? {}, 'stream_options'), false,
|
||||
`${model} retry must omit stream_options`)
|
||||
}
|
||||
|
||||
const ordinary = await captureError('ordinary-400')
|
||||
assert.equal(ordinary.status, 400)
|
||||
assert.equal(attempts('ordinary-400').length, 1, 'an ordinary 400 must not retry')
|
||||
assert.equal(ordinary.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
|
||||
'upstream body content must not be copied into the renderer-facing message')
|
||||
|
||||
const auth = await captureError('auth-401')
|
||||
assert.equal(auth.status, 401)
|
||||
assert.equal(attempts('auth-401').length, 1, 'authentication failures must not retry')
|
||||
assert.equal(auth.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
|
||||
'authorization material must not be copied into the renderer-facing message')
|
||||
|
||||
const serverFailure = await captureError('server-500-options')
|
||||
assert.equal(serverFailure.status, 500)
|
||||
assert.equal(attempts('server-500-options').length, 1,
|
||||
'a 5xx response must not retry even when its body names stream_options')
|
||||
|
||||
const startedSse = await captureError('started-sse-error')
|
||||
assert.equal(attempts('started-sse-error').length, 1,
|
||||
'an error after SSE has started must not trigger another request')
|
||||
assert.match(startedSse.message, /上游错误/)
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = collect('abort-retry', controller.signal)
|
||||
await waitForAttempts('abort-retry', 2)
|
||||
controller.abort()
|
||||
await assert.rejects(aborted, (error: unknown) =>
|
||||
error instanceof Error && error.name === 'AbortError')
|
||||
assert.equal(attempts('abort-retry').length, 2,
|
||||
'the shared AbortSignal must cancel the in-flight compatibility retry')
|
||||
|
||||
console.log('chat-completions-usage-regression: all checks passed')
|
||||
} finally {
|
||||
for (const response of abortConnections) response.destroy()
|
||||
server.closeAllConnections?.()
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
@@ -13,9 +13,7 @@ interface PersistenceTestState {
|
||||
promptCalls: number
|
||||
abortCalls: number
|
||||
cancelOutputEmitted: boolean
|
||||
useOmp: boolean
|
||||
fallbackHistory: ChatMessage[] | null
|
||||
fallbackWireHistory?: Array<{ role: string; content: string }>
|
||||
resumeAts: Array<string | undefined>
|
||||
}
|
||||
|
||||
const TEST_STATE_KEY = '__YCODE_CHAT_PERSISTENCE_TEST__'
|
||||
@@ -73,8 +71,7 @@ async function main(): Promise<void> {
|
||||
promptCalls: 0,
|
||||
abortCalls: 0,
|
||||
cancelOutputEmitted: false,
|
||||
useOmp: true,
|
||||
fallbackHistory: null
|
||||
resumeAts: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
|
||||
@@ -134,74 +131,74 @@ async function main(): Promise<void> {
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/claude\/claude-kernel$/ }, () => ({ path: 'claude-kernel', namespace: 'mock-claude-kernel' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-claude-kernel' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
const toolId = 'persisted-tool';
|
||||
const fullOutput = '${'long-output-'.repeat(60)}PERSISTED-OUTPUT-SENTINEL';
|
||||
const emit = (kernel, frame) => kernel.emit('event', frame);
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor() { super(); }
|
||||
setThinkingLevel() { return Promise.resolve(); }
|
||||
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
|
||||
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
|
||||
prompt(message) {
|
||||
const kernels = new Map();
|
||||
class MockClaudeKernel {
|
||||
constructor(options) { this.options = options; this.busy = false; this.active = null; }
|
||||
async ensureStarted() {}
|
||||
async setPolicy() {}
|
||||
async runTurn(request) {
|
||||
state.promptCalls++;
|
||||
if (message.split('\\n', 1)[0] === 'persist-cancel') {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'cancel-before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'shell', args: { command: 'long-running' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: 'cancelled tool output retained' } });
|
||||
state.cancelOutputEmitted = true;
|
||||
});
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { input: 1200, output: 10, totalTokens: 1210 } } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'read_file', args: { path: 'large.txt' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: fullOutput } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'after' } });
|
||||
emit(this, { type: 'tool_execution_end', toolCall: { id: toolId, name: 'read_file', result: { content: [{ type: 'text', text: fullOutput }], isError: false }, success: true } });
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { contextTokens: 12800, input: 12000, cacheRead: 800, output: 200, totalTokens: 13000 } } });
|
||||
emit(this, { type: 'agent_end', yielded: true });
|
||||
this.busy = true;
|
||||
const cancelled = request.prompt.startsWith('persist-cancel');
|
||||
if (cancelled) {
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'cancel-before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'running', output: 'cancelled tool output retained'
|
||||
} });
|
||||
state.cancelOutputEmitted = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
state.abortCalls++;
|
||||
this.busy = false;
|
||||
reject(Object.assign(new Error('回合已取消'), { name: 'AbortError' }));
|
||||
};
|
||||
if (request.signal.aborted) abort();
|
||||
else request.signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
const tool = {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'ok', output: fullOutput, summary: fullOutput.slice(0, 600), result: { content: fullOutput }
|
||||
};
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: { ...tool, status: 'running', output: undefined, summary: undefined, result: undefined } });
|
||||
request.emit({ runId: request.runId, type: 'tool_result', call: tool });
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'after' });
|
||||
request.emit({ runId: request.runId, type: 'usage', usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 } });
|
||||
this.busy = false;
|
||||
return { content: 'beforeafter', tools: [tool], status: 'done', entryId: 'mock-claude-entry' };
|
||||
}
|
||||
abort() {
|
||||
state.abortCalls++;
|
||||
setImmediate(() => emit(this, { type: 'agent_end', yielded: true }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
dispose() {}
|
||||
async rewindFiles() {}
|
||||
async close() { this.busy = false; }
|
||||
}
|
||||
export const detectOmpPath = () => state.useOmp ? 'mock-omp' : null;
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-loop' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-loop' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
export async function runTurn(options) {
|
||||
state.fallbackHistory = options.history;
|
||||
state.fallbackWireHistory = options.wireHistory;
|
||||
const content = 'fallback response';
|
||||
options.emit({ runId: options.runId, type: 'text', delta: content });
|
||||
options.emit({ runId: options.runId, type: 'done', content, tools: [] });
|
||||
return { content, tools: [] };
|
||||
export function getClaudeKernel(options) {
|
||||
state.resumeAts.push(options.resumeAt);
|
||||
let kernel = kernels.get(options.sessionId);
|
||||
if (!kernel) { kernel = new MockClaudeKernel(options); kernels.set(options.sessionId, kernel); }
|
||||
return kernel;
|
||||
}
|
||||
export const cachedClaudeKernel = (sessionId) => kernels.get(sessionId);
|
||||
export const releaseClaudeKernel = async (sessionId, force) => {
|
||||
if (!force) return;
|
||||
const kernel = kernels.get(sessionId);
|
||||
if (!kernel) return;
|
||||
kernels.delete(sessionId);
|
||||
await kernel.close();
|
||||
};
|
||||
export const setSessionVisible = () => {};
|
||||
export const removeSessionView = () => {};
|
||||
export const disposeClaudeKernels = async () => { for (const kernel of kernels.values()) await kernel.close(); kernels.clear(); };
|
||||
export const claudeRuntimeInfo = () => ({ available: false, exePath: null, sessionDir: state.userData });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
|
||||
loader: 'js',
|
||||
contents: 'export class Gateway { constructor() {} }'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -235,6 +232,7 @@ async function main(): Promise<void> {
|
||||
]
|
||||
assert.deepEqual(normalAssistant.parts, expectedParts, 'session:messages should retain text/tool/text order')
|
||||
assert.equal(normalAssistant.status, 'done')
|
||||
assert.equal(normalAssistant.claudeEntryId, 'mock-claude-entry')
|
||||
assert.equal(normalAssistant.tools?.length, 1)
|
||||
const normalTool = normalAssistant.tools?.[0]
|
||||
assert.ok(normalTool, 'completed tool should be present')
|
||||
@@ -243,7 +241,8 @@ async function main(): Promise<void> {
|
||||
assert.ok((normalTool.summary?.length ?? Infinity) < (normalTool.output?.length ?? 0),
|
||||
'the summary may be truncated while full output remains intact')
|
||||
|
||||
const diskNormalAssistant = assistant(await readDiskMessages(state.userData, normalSession.id))
|
||||
const diskNormalAssistant = await waitForDiskAssistant(state.userData, normalSession.id,
|
||||
(message) => message.status === 'done' && message.claudeEntryId === 'mock-claude-entry')
|
||||
assert.deepEqual(normalAssistant.contextUsage, {
|
||||
usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 }, contextWindow: 64000,
|
||||
modelId:'usage-snapshot-model',requestId:normalAssistant.requestId,source:'upstream',sampledAt:normalAssistant.contextUsage?.sampledAt
|
||||
@@ -252,45 +251,32 @@ async function main(): Promise<void> {
|
||||
assert.deepEqual(diskNormalAssistant.contextUsage, normalAssistant.contextUsage,
|
||||
'the checkpoint whitelist must persist the upstream sample and frozen denominator together')
|
||||
assert.deepEqual(diskNormalAssistant.parts, expectedParts, 'disk JSON should retain text/tool/text order')
|
||||
assert.equal(diskNormalAssistant.claudeEntryId, 'mock-claude-entry',
|
||||
'disk JSON should retain the Claude fork point')
|
||||
assert.ok((diskNormalAssistant.tools?.[0].output?.length ?? 0) > 600,
|
||||
'disk JSON should retain output beyond the summary limit')
|
||||
assert.equal(diskNormalAssistant.tools?.[0].output, normalTool.output,
|
||||
'disk JSON should retain the complete tool output')
|
||||
assert.ok(diskNormalAssistant.tools?.[0].output?.endsWith('PERSISTED-OUTPUT-SENTINEL'))
|
||||
|
||||
const fallbackSession = (await invoke('session:create', 'fallback history')) as { id: string }
|
||||
const sessions = ipc.sessions as {
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
}
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-user',
|
||||
role: 'user',
|
||||
content: 'old request',
|
||||
quotes: [{ id: 'saved-table', sourceId: 'table-source', kind: 'table', markdown: '| name | value |\n| --- | --- |\n| A | 42 |', preview: 'A 42' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
await invoke('chat:send', { sessionId: normalSession.id, text: 'persist-second' })
|
||||
const secondMessages = await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
const secondUser = secondMessages.filter((message) => message.role === 'user').at(-1)
|
||||
assert.ok(secondUser)
|
||||
await invoke('chat:send', {
|
||||
sessionId: normalSession.id,
|
||||
text: 'persist-edited',
|
||||
fromMessageId: secondUser.id,
|
||||
action: 'edit',
|
||||
rollbackArtifacts: false
|
||||
})
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-assistant',
|
||||
role: 'assistant',
|
||||
content: 'old answer',
|
||||
parts: [{ type: 'text', text: 'old answer' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
})
|
||||
state.useOmp = false
|
||||
await invoke('chat:send', { sessionId: fallbackSession.id, text: 'fallback-history' })
|
||||
const fallbackMessages = await waitForMessages(
|
||||
invoke,
|
||||
fallbackSession.id,
|
||||
(messages) => assistant(messages).content === 'fallback response' && assistant(messages).status === 'done'
|
||||
)
|
||||
assert.equal(assistant(fallbackMessages).content, 'fallback response')
|
||||
assert.deepEqual(state.fallbackHistory?.map((message) => message.content), ['old request', 'old answer'],
|
||||
'the fallback loop should receive previously persisted user and assistant messages')
|
||||
assert.equal(state.fallbackWireHistory?.[0].content, 'old request\n\n| name | value |\n| --- | --- |\n| A | 42 |',
|
||||
'the next fallback request must include the exact previously quoted table snapshot')
|
||||
state.useOmp = true
|
||||
await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
assert.equal(state.resumeAts.at(-1), normalAssistant.claudeEntryId,
|
||||
'editing a prior turn should fork at the last kept Claude chain entry')
|
||||
|
||||
const cancelSession = (await invoke('session:create', 'cancelled persistence')) as { id: string }
|
||||
const cancelledSend = (await invoke('chat:send', {
|
||||
@@ -298,7 +284,7 @@ async function main(): Promise<void> {
|
||||
text: 'persist-cancel'
|
||||
})) as { runId: string }
|
||||
for (let attempt = 0; attempt < 100 && !state.cancelOutputEmitted; attempt++) await wait(10)
|
||||
assert.equal(state.cancelOutputEmitted, true, 'fake OMP should emit a running tool and partial output')
|
||||
assert.equal(state.cancelOutputEmitted, true, 'the mock Claude kernel should emit a running tool and partial output')
|
||||
const streamingCheckpoint = await waitForDiskAssistant(state.userData, cancelSession.id, (message) =>
|
||||
message.status === 'streaming' &&
|
||||
message.content === 'cancel-before' &&
|
||||
@@ -331,7 +317,7 @@ async function main(): Promise<void> {
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].status, 'cancelled', 'disk JSON should persist terminal tool status')
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].output, 'cancelled tool output retained',
|
||||
'disk JSON should preserve output from a running tool cancelled mid-turn')
|
||||
assert.equal(state.promptCalls, 2)
|
||||
assert.equal(state.promptCalls, 4)
|
||||
assert.equal(state.abortCalls, 1)
|
||||
|
||||
console.log('chat-persistence-regression: all checks passed')
|
||||
|
||||
@@ -104,6 +104,7 @@ function installFakeWindow(send: (payload: { sessionId: string; text: string })
|
||||
},
|
||||
chat: {
|
||||
send,
|
||||
setSessionVisible: async () => ({ accepted: true }),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
revert: async () => ({ messages: [] }),
|
||||
activeRun: async (sessionId: string) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { claudeAnthropicBaseUrl } from '../src/main/agent/claude/claude-endpoint'
|
||||
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/v1'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/v1/messages'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/anthropic/v1'), 'https://api.example.com/anthropic')
|
||||
assert.equal(claudeAnthropicBaseUrl('http://gateway.local:8080/proxy/v1/'), 'http://gateway.local:8080/proxy')
|
||||
|
||||
console.log('{"result":"PASS","cases":6}')
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
import type { ResolvedModel } from '../src/shared/models'
|
||||
import type { InteractionBroker } from '../src/main/interaction-broker'
|
||||
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
|
||||
|
||||
interface CapturedRequest {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
userText: string
|
||||
hasToolResult: boolean
|
||||
}
|
||||
|
||||
const requests: CapturedRequest[] = []
|
||||
const events: ChatEvent[] = []
|
||||
const persistedSessionIds: string[] = []
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-kernel-'))
|
||||
process.env.CLAUDE_TEST_USER_DATA = join(temp, 'user-data')
|
||||
let askToolIssued = false
|
||||
let planToolIssued = false
|
||||
let pendingToolCompletion: 'ask' | 'plan' | 'write' | null = null
|
||||
|
||||
function sse(res: import('node:http').ServerResponse, event: string, data: unknown): void {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function messageStart(res: import('node:http').ServerResponse, id: string): void {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model: 'claude-mock',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 8, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageEnd(res: import('node:http').ServerResponse, stopReason: string): void {
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 4 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function textBlock(res: import('node:http').ServerResponse, text: string, index = 0): void {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index,
|
||||
delta: { type: 'text_delta', text }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
|
||||
}
|
||||
|
||||
function toolBlock(
|
||||
res: import('node:http').ServerResponse,
|
||||
id: string,
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
index: number
|
||||
): void {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index,
|
||||
content_block: { type: 'tool_use', id, name, input: {} }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index,
|
||||
delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
|
||||
}
|
||||
|
||||
function plainText(res: import('node:http').ServerResponse, id: string, text: string): void {
|
||||
messageStart(res, id)
|
||||
textBlock(res, text)
|
||||
messageEnd(res, 'end_turn')
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => { raw += chunk })
|
||||
req.on('end', () => {
|
||||
let body: Record<string, unknown> = {}
|
||||
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
|
||||
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
|
||||
const latest = messages.at(-1)
|
||||
const latestContent = JSON.stringify(latest?.content ?? '')
|
||||
const userText = messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map((message) => JSON.stringify(message.content))
|
||||
.join('\n')
|
||||
requests.push({ url: req.url ?? '', body, userText, hasToolResult: latestContent.includes('"tool_result"') })
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
|
||||
console.error('[kernel-test] request', requests.length, req.url, {
|
||||
ask: userText.includes('ASK_SMOKE'),
|
||||
plan: userText.includes('PLAN_SMOKE'),
|
||||
resume: userText.includes('RESUME_SMOKE'),
|
||||
hasToolResult: requests.at(-1)?.hasToolResult
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{}')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const id = `msg_kernel_${requests.length}`
|
||||
if (userText.includes('Write the title in the predominant language')) {
|
||||
plainText(res, id, 'Plan Smoke')
|
||||
return
|
||||
}
|
||||
if (userText.includes('RESUME_SMOKE')) {
|
||||
plainText(res, id, 'Resumed.')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'ask') {
|
||||
pendingToolCompletion = null
|
||||
plainText(res, id, 'Question answered.')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'plan') {
|
||||
pendingToolCompletion = 'write'
|
||||
messageStart(res, id)
|
||||
toolBlock(res, 'write_tool', 'Write', {
|
||||
file_path: join(temp, 'blocked.txt'),
|
||||
content: 'This must not be written in plan mode.'
|
||||
}, 0)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'write') {
|
||||
pendingToolCompletion = null
|
||||
plainText(res, id, 'Plan ready.')
|
||||
return
|
||||
}
|
||||
if (userText.includes('ASK_SMOKE') && !askToolIssued) {
|
||||
askToolIssued = true
|
||||
pendingToolCompletion = 'ask'
|
||||
messageStart(res, id)
|
||||
toolBlock(res, 'ask_tool', 'AskUserQuestion', {
|
||||
questions: [{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'Alpha', description: 'First option' },
|
||||
{ label: 'Beta', description: 'Second option' }
|
||||
]
|
||||
}]
|
||||
}, 0)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
if (userText.includes('PLAN_SMOKE') && !planToolIssued) {
|
||||
planToolIssued = true
|
||||
pendingToolCompletion = 'plan'
|
||||
messageStart(res, id)
|
||||
textBlock(res, 'Plan:\n1. Inspect files\n2. Implement change')
|
||||
toolBlock(res, 'plan_tool', 'ExitPlanMode', {
|
||||
plan: 'Plan:\n1. Inspect files\n2. Implement change',
|
||||
planFilePath: join(temp, 'plan.md')
|
||||
}, 1)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
plainText(res, id, 'Unexpected prompt.')
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('mock server did not bind')
|
||||
|
||||
const model = {
|
||||
profile: {
|
||||
id: 'mock-model', name: 'Mock Claude', model: 'claude-mock', authMode: 'api-key',
|
||||
protocol: 'anthropic', contextWindowTokens: 200000
|
||||
},
|
||||
config: {
|
||||
api: 'anthropic-messages',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
model: 'claude-mock',
|
||||
apiKey: 'ycode-test-key',
|
||||
authMode: 'api-key'
|
||||
}
|
||||
} as unknown as ResolvedModel
|
||||
|
||||
const questions: unknown[] = []
|
||||
const interactions = {
|
||||
async question(_toolCallId: string, _title: string, items: Array<{ id: string; question: string; options: unknown[] }>) {
|
||||
questions.push(items)
|
||||
return {
|
||||
kind: 'question' as const,
|
||||
answer: {
|
||||
questions: items.map((item) => ({ id: item.id, question: item.question, answers: ['Alpha'] }))
|
||||
}
|
||||
}
|
||||
},
|
||||
async approval() {
|
||||
throw new Error('approval was not expected in this regression')
|
||||
}
|
||||
} as unknown as InteractionBroker
|
||||
|
||||
function request(prompt: string, userMessageId: string, controller: AbortController) {
|
||||
return {
|
||||
runId: randomUUID(),
|
||||
userMessageId,
|
||||
prompt,
|
||||
emit: (event: ChatEvent) => events.push(event),
|
||||
interactions,
|
||||
signal: controller.signal
|
||||
}
|
||||
}
|
||||
|
||||
let kernel: ClaudeKernel | null = null
|
||||
let resumed: ClaudeKernel | null = null
|
||||
try {
|
||||
const sessionId = randomUUID()
|
||||
kernel = new ClaudeKernel({
|
||||
sessionId,
|
||||
workspaceRoot: temp,
|
||||
model,
|
||||
permissionMode: 'default-sandbox',
|
||||
agentMode: 'craft',
|
||||
thinkingLevel: 'off',
|
||||
forceNewSession: true,
|
||||
onSessionId: (id) => { persistedSessionIds.push(id) }
|
||||
})
|
||||
const first = await kernel.runTurn(request('ASK_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] first complete')
|
||||
assert.equal(first.content, 'Question answered.')
|
||||
assert.equal(first.tools.length, 1)
|
||||
assert.equal(questions.length, 1)
|
||||
assert.deepEqual(
|
||||
(questions[0] as Array<{ options: Array<{ question: string; option: string; id: string }> }>)[0].options.map((option) => option.question),
|
||||
['Alpha', 'Beta']
|
||||
)
|
||||
assert.ok(persistedSessionIds.includes(kernel.sessionId), 'the initial Claude session id should be persisted')
|
||||
|
||||
// Simulate an abnormal process exit while keeping the same ClaudeKernel alive.
|
||||
const runningKernel = kernel as unknown as { instance: { close(): void } | null }
|
||||
runningKernel.instance?.close()
|
||||
for (let index = 0; index < 200 && runningKernel.instance; index++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.equal(runningKernel.instance, null, 'Claude process should be released before restart')
|
||||
|
||||
await kernel.setPolicy('default-sandbox', 'plan')
|
||||
const second = await kernel.runTurn(request('PLAN_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] second complete')
|
||||
assert.ok(second.content.endsWith('Plan ready.'))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
|
||||
console.error('[kernel-test] events', events.map((event) => event.type === 'part' ? `${event.type}:${event.part.type}` : event.type))
|
||||
console.error('[kernel-test] second tools', second.tools)
|
||||
}
|
||||
const plan = events.find((event) => event.type === 'part' && event.part.type === 'plan')
|
||||
assert.ok(plan && plan.type === 'part' && plan.part.type === 'plan')
|
||||
assert.deepEqual(plan.part.plan.steps.map((step) => step.text), ['Plan:', 'Inspect files', 'Implement change'])
|
||||
assert.equal(second.tools.find((tool) => tool.name === 'ExitPlanMode')?.status, 'error')
|
||||
assert.equal(second.tools.find((tool) => tool.name === 'Write')?.status, 'error')
|
||||
assert.equal(existsSync(join(temp, 'blocked.txt')), false)
|
||||
|
||||
const runtimeSessionId = kernel.sessionId
|
||||
assert.ok(runtimeSessionId)
|
||||
assert.ok(requests.some((item) => item.url.includes('/v1/messages')))
|
||||
assert.equal(requests.some((item) => item.url.includes('/v1/v1/')), false)
|
||||
|
||||
await kernel.close()
|
||||
kernel = null
|
||||
resumed = new ClaudeKernel({
|
||||
sessionId,
|
||||
workspaceRoot: temp,
|
||||
model,
|
||||
permissionMode: 'default-sandbox',
|
||||
agentMode: 'craft',
|
||||
thinkingLevel: 'off',
|
||||
resumeSessionId: runtimeSessionId,
|
||||
onSessionId: () => undefined
|
||||
})
|
||||
const third = await resumed.runTurn(request('RESUME_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] third complete')
|
||||
assert.equal(third.content, 'Resumed.')
|
||||
const resumedRequest = [...requests].reverse().find((item) => item.userText.includes('RESUME_SMOKE'))
|
||||
assert.ok(resumedRequest)
|
||||
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
|
||||
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
|
||||
console.log(JSON.stringify({
|
||||
result: 'PASS',
|
||||
requests: requests.length,
|
||||
events: events.length,
|
||||
turns: 3,
|
||||
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
|
||||
}))
|
||||
} finally {
|
||||
await resumed?.close().catch(() => undefined)
|
||||
await kernel?.close().catch(() => undefined)
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()))
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createSdkMcpServer, query, tool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
|
||||
const executable = resolve(process.env.YCODE_CLAUDE_EXE ?? 'node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/claude.exe')
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-smoke-'))
|
||||
const requests = []
|
||||
|
||||
function sse(res, event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function textResponse(res, id, model, text) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model,
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
sse(res, 'content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })
|
||||
sse(res, 'content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } })
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 5 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', chunk => { raw += chunk })
|
||||
req.on('end', () => {
|
||||
let body
|
||||
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
|
||||
requests.push({ url: req.url, method: req.method, body })
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ data: [] }))
|
||||
return
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const id = `msg_smoke_${requests.length}`
|
||||
const latestUser = [...(body.messages ?? [])].reverse().find(message => message.role === 'user')
|
||||
const latestUserContent = JSON.stringify(latestUser?.content ?? [])
|
||||
if (process.env.DEBUG_CLAUDE_SMOKE === '1') console.error('smoke request', requests.length, latestUserContent.slice(0, 500))
|
||||
const hasToolResult = latestUserContent.includes('"tool_result"')
|
||||
const wantsRead = latestUserContent.includes('Read the smoke file.')
|
||||
if (hasToolResult) {
|
||||
textResponse(res, id, body.model ?? 'claude-smoke', 'Tool read complete.')
|
||||
return
|
||||
}
|
||||
if (wantsRead && !hasToolResult) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model: body.model ?? 'claude-smoke',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index: 0,
|
||||
content_block: { type: 'tool_use', id: 'tool_smoke', name: 'Read', input: {} }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: JSON.stringify({ file_path: join(temp, 'smoke.txt') }) }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'tool_use', stop_sequence: null },
|
||||
usage: { output_tokens: 5 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
textResponse(res, id, body.model ?? 'claude-smoke', 'Claude mock reply.')
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('mock server did not bind')
|
||||
|
||||
const sessionId = crypto.randomUUID()
|
||||
let runtimeSessionId = sessionId
|
||||
let releaseSecondTurn
|
||||
const firstTurnComplete = new Promise(resolve => { releaseSecondTurn = resolve })
|
||||
const input = (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Read the smoke file.' }] },
|
||||
parent_tool_use_id: null,
|
||||
uuid: crypto.randomUUID(),
|
||||
session_id: runtimeSessionId
|
||||
}
|
||||
await firstTurnComplete
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Reply once more.' }] },
|
||||
parent_tool_use_id: null,
|
||||
uuid: crypto.randomUUID(),
|
||||
session_id: runtimeSessionId
|
||||
}
|
||||
})()
|
||||
|
||||
await writeFile(join(temp, 'smoke.txt'), 'SMOKE_FILE_CONTENTS', 'utf8')
|
||||
|
||||
const messages = []
|
||||
const results = []
|
||||
let instance
|
||||
try {
|
||||
instance = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd: temp,
|
||||
model: 'claude-smoke',
|
||||
pathToClaudeCodeExecutable: executable,
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
ANTHROPIC_API_KEY: 'ycode-smoke-key',
|
||||
CLAUDE_CONFIG_DIR: join(temp, 'claude-config'),
|
||||
CLAUDE_CODE_ENABLE_TODO_TOOLS: '1',
|
||||
CLAUDE_CODE_ENABLE_TASKS: '0',
|
||||
...(process.platform === 'win32' ? { CLAUDE_CODE_USE_POWERSHELL_TOOL: '1' } : {})
|
||||
},
|
||||
includePartialMessages: true,
|
||||
permissionMode: 'default',
|
||||
permissionPrompts: 'host',
|
||||
settingSources: [],
|
||||
strictMcpConfig: true,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'PowerShell', 'WebFetch', 'WebSearch',
|
||||
'TodoWrite', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'],
|
||||
allowedTools: [],
|
||||
disallowedTools: ['Agent', 'Task', 'Skill', 'Workflow', 'CronCreate', 'CronDelete', 'CronList'],
|
||||
plugins: [],
|
||||
agents: {},
|
||||
skills: [],
|
||||
mcpServers: {
|
||||
ycode: createSdkMcpServer({
|
||||
name: 'ycode',
|
||||
tools: [tool('ping', 'Test host tool', { value: z.string() }, async ({ value }) => ({
|
||||
content: [{ type: 'text', text: value }]
|
||||
}))]
|
||||
})
|
||||
},
|
||||
canUseTool: async (toolName) => toolName === 'Read'
|
||||
? { behavior: 'allow', updatedInput: { file_path: join(temp, 'smoke.txt') } }
|
||||
: { behavior: 'deny', message: 'Only Read is enabled in this smoke test.' }
|
||||
}
|
||||
})
|
||||
for await (const message of instance) {
|
||||
messages.push(message)
|
||||
if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
|
||||
runtimeSessionId = message.session_id
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
results.push(message)
|
||||
if (results.length === 1) releaseSecondTurn()
|
||||
else break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
instance?.close()
|
||||
await new Promise(resolveClose => server.close(resolveClose))
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const result = results.at(-1)
|
||||
const streamed = messages.filter(message => message.type === 'stream_event')
|
||||
.map(message => message.event)
|
||||
.filter(event => event?.type === 'content_block_delta' && event.delta?.type === 'text_delta')
|
||||
.map(event => event.delta.text)
|
||||
.join('')
|
||||
const toolResult = messages.find(message => message.type === 'user' &&
|
||||
Array.isArray(message.message?.content) &&
|
||||
message.message.content.some(block => block.type === 'tool_result'))
|
||||
|
||||
assert.ok(requests.some(request => request.url?.includes('/messages')), 'Claude Code should call the Anthropic Messages endpoint')
|
||||
assert.equal(results.length, 2, 'one process should serve two consecutive turns')
|
||||
assert.equal(new Set(results.map(message => message.session_id)).size, 1,
|
||||
'both turns should stay in the same Claude session')
|
||||
assert.equal(streamed, 'Tool read complete.Claude mock reply.')
|
||||
assert.ok(toolResult, 'the native Read tool result should return to Claude')
|
||||
assert.equal(result?.subtype, 'success')
|
||||
assert.equal(result?.result, 'Claude mock reply.')
|
||||
const init = messages.find(message => message.type === 'system' && message.subtype === 'init')
|
||||
assert.ok(init?.tools.includes('TodoWrite'), 'TodoWrite should be enabled')
|
||||
if (process.platform === 'win32') assert.ok(init?.tools.includes('PowerShell'), 'PowerShell should be enabled on Windows')
|
||||
assert.equal(init?.tools.some(tool => ['Agent', 'Task', 'Skill', 'Workflow', 'CronCreate'].includes(tool)), false)
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', requests: requests.length, messages: messages.length, turns: results.length, text: result.result }))
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { planFromExitInput, questionsFromInput } from '../src/main/agent/claude/claude-tool-input'
|
||||
|
||||
const questions = questionsFromInput({
|
||||
questions: [{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'First', description: 'First option' },
|
||||
{ label: 'Second', description: 'Second option' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
assert.equal(questions.length, 1)
|
||||
assert.equal(questions[0].id, 'question-0')
|
||||
assert.deepEqual(questions[0].options[0], {
|
||||
id: 'First',
|
||||
label: 'First',
|
||||
question: 'First',
|
||||
option: 'First',
|
||||
description: 'First option'
|
||||
})
|
||||
|
||||
const plan = planFromExitInput({}, 'Plan:\n1. Inspect files\n2. Implement change')
|
||||
assert.deepEqual(plan.steps.map((step) => step.text), ['Plan:', 'Inspect files', 'Implement change'])
|
||||
assert.equal(plan.steps[0].status, 'pending')
|
||||
|
||||
console.log('{"result":"PASS","questions":1,"planSteps":3}')
|
||||
@@ -1,53 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { contextObserverSource, observeOmpContextSamples, prepareContextObserver } from '../src/main/omp-context-observer'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ycode-observer-'))
|
||||
const previous = { config: process.env.YCODE_CONTEXT_REQUEST_FILE, sample: process.env.YCODE_CONTEXT_SAMPLE_FILE }
|
||||
let stop: (() => void) | undefined
|
||||
try {
|
||||
const paths = await prepareContextObserver(directory, 'session')
|
||||
process.env.YCODE_CONTEXT_REQUEST_FILE = paths.configPath
|
||||
process.env.YCODE_CONTEXT_SAMPLE_FILE = paths.samplePath
|
||||
await writeFile(paths.configPath, JSON.stringify({ runId: 'run', sessionPath: '/fixture.jsonl', modelId: 'selected', modelName: 'fixture', origins: [], toolCategories: {} }))
|
||||
const emitter = new EventEmitter(), frames: Array<{ type: string; breakdown?: { sequence: number } }> = []
|
||||
stop = observeOmpContextSamples(emitter as OmpKernel, paths.samplePath)
|
||||
emitter.on('event', frame => frames.push(frame))
|
||||
const extension = await import(pathToFileURL(paths.extensionPath).href)
|
||||
let hook: (event: unknown, ctx: unknown) => void = () => { throw Error('hook missing') }
|
||||
extension.default({ on(name: string, callback: typeof hook) { assert.equal(name, 'before_provider_request'); hook = callback } })
|
||||
const event = { payload: { messages: [{ role: 'user', content: 'POISON_NEVER_PERSIST_PROMPT' }], tools: [] } }
|
||||
const context = { sessionManager: { getSessionFile: () => '/fixture.jsonl' }, model: { id: 'fixture' } }
|
||||
const write = process.stdout.write
|
||||
let stdoutCalls = 0
|
||||
try { process.stdout.write = (() => { stdoutCalls++; return true }) as typeof process.stdout.write; hook(event, context) }
|
||||
finally { process.stdout.write = write }
|
||||
assert.equal(stdoutCalls, 0, 'observer must not write into the native JSONL channel')
|
||||
assert.ok(!contextObserverSource().includes('process.stdout'), 'extension cannot interleave its data with an image frame')
|
||||
const sample = await readFile(paths.samplePath, 'utf8')
|
||||
assert.ok(!sample.includes('POISON_NEVER_PERSIST_PROMPT'), 'only counts and identity leave the request observer')
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames[0].type, 'ycode_context_breakdown', 'sample is observed before request-end usage')
|
||||
assert.equal(frames[0].breakdown?.sequence, 1)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.filter(frame => frame.type === 'ycode_context_breakdown').length, 1, 'duplicate notification does not clear a newer provider total')
|
||||
hook(event, context)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.at(-2)?.breakdown?.sequence, 2, 'new prepared input precedes the next provider usage frame')
|
||||
stop(); stop = undefined
|
||||
const count = frames.length
|
||||
hook(event, context)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.length, count + 1, 'retired observers stop injecting sample frames')
|
||||
console.log('CONTEXT_OBSERVER_TRANSPORT_PASS 7 checks')
|
||||
} finally {
|
||||
stop?.()
|
||||
if (previous.config === undefined) delete process.env.YCODE_CONTEXT_REQUEST_FILE; else process.env.YCODE_CONTEXT_REQUEST_FILE = previous.config
|
||||
if (previous.sample === undefined) delete process.env.YCODE_CONTEXT_SAMPLE_FILE; else process.env.YCODE_CONTEXT_SAMPLE_FILE = previous.sample
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp,writeFile,rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createContextUsageService,recoverNativeUsage } from '../src/main/context-usage-service'
|
||||
import { estimateContextInput,contextInputOrigins } from '../src/main/context-usage-estimator'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import { contextInfoFromMessages } from '../src/shared/context-usage'
|
||||
import type { ChatMessage,ContextUsageBreakdown } from '../src/shared/types'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
|
||||
const lines=(entries:unknown[])=>entries.map(entry=>JSON.stringify(entry)).join('\n')
|
||||
const user={id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'hello'}]}}
|
||||
const first={id:'native-a1',parentId:'native-user',type:'message',message:{role:'assistant',model:'fixture',usage:{input:50,output:10},stopReason:'toolUse',timestamp:100}}
|
||||
const last={id:'native-a2',parentId:'native-a1',type:'message',message:{role:'assistant',model:'fixture',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:200}}
|
||||
const history=lines([user,first,last])
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.inputTokens,5161,'cache input belongs to input occupancy, not output')
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.totalTokens,5286)
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.nativeEntryId,'native-a2','use the latest request, never the sum of tool rounds')
|
||||
assert.equal(recoverNativeUsage(lines([user,first,last,{...last,id:'sibling',parentId:'native-user'}]),'native-user'),undefined,'sibling histories cannot be guessed')
|
||||
assert.equal(recoverNativeUsage(lines([user,first,{...last,message:{...last.message,stopReason:'error'}}]),'native-user'),undefined,'a failed final request does not inherit earlier tool usage')
|
||||
|
||||
const directory=await mkdtemp(join(tmpdir(),'ycode-context-'))
|
||||
try {
|
||||
const path=join(directory,'native.jsonl')
|
||||
await writeFile(path,history)
|
||||
let messages:ChatMessage[]=[{id:'user',role:'user',content:'hello',createdAt:1,requestId:'run',nativeEntryId:'native-user',nativeSessionPath:path},
|
||||
{id:'assistant',role:'assistant',content:'done',createdAt:2,requestId:'run',modelId:'model',status:'done'}]
|
||||
let persisted=0,running=false
|
||||
let live:(()=>Promise<unknown>)|undefined
|
||||
const service=createContextUsageService({messages:async()=>messages,modelId:async()=> 'model',nativePath:()=>path,running:()=>running,
|
||||
liveState:()=>live?.(),persistIfCurrent:async(_id,assistantId,requestId,sample)=>{
|
||||
const current=messages.findLast(message=>message.role==='assistant')
|
||||
if(!running&¤t?.id===assistantId&¤t.requestId===requestId){current.contextUsage=sample;persisted++}
|
||||
}})
|
||||
const restored=await service.get('session')
|
||||
assert.equal(restored.sample?.usage.inputTokens,5161)
|
||||
assert.equal(restored.sample?.source,'native-history')
|
||||
assert.equal(restored.sample?.contextWindow,undefined,'do not invent a historical denominator from current settings')
|
||||
assert.equal(persisted,1)
|
||||
await service.get('session');assert.equal(persisted,1,'reading a cached sample does not rewrite history repeatedly')
|
||||
messages[1].contextUsage=undefined
|
||||
live=async()=>({success:true,data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
|
||||
assert.equal((await service.get('session')).sample?.contextWindow,64000,'matching live native state can supply its window')
|
||||
messages[1].contextUsage=undefined
|
||||
messages[0].nativeSessionPath=join(directory,'outside.jsonl')
|
||||
assert.equal((await service.get('session')).sample,undefined,'a message cannot redirect the reader to an arbitrary file')
|
||||
messages[0].nativeSessionPath=path
|
||||
running=true
|
||||
assert.equal((await service.get('session')).sample,undefined,'querying a running turn must not recover a previous round')
|
||||
running=false
|
||||
let release!:(value:unknown)=>void
|
||||
live=()=>new Promise(resolve=>{release=resolve})
|
||||
const stale=service.get('session')
|
||||
while(!release)await new Promise(resolve=>setTimeout(resolve,1))
|
||||
messages=[...messages,{id:'new-user',role:'user',content:'next',createdAt:3},{id:'new-assistant',role:'assistant',content:'',createdAt:4,status:'streaming',requestId:'next'}]
|
||||
release({data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
|
||||
assert.equal((await stale).sample,undefined,'a delayed query cannot overwrite a newer turn')
|
||||
assert.equal(persisted,2)
|
||||
assert.equal(contextInfoFromMessages(messages).sample,undefined,'no traversal past the latest assistant')
|
||||
}finally{await rm(directory,{recursive:true,force:true})}
|
||||
|
||||
const skill='<skill id="skill:test">skill instructions</skill>'
|
||||
const injected=`Project rules\n\n${skill}`
|
||||
const input={systemPrompt:'Base system instructions',origins:contextInputOrigins([injected]),
|
||||
tools:[{type:'function',function:{name:'unused_tool',description:'This declaration is present without a call',parameters:{type:'object'}}},
|
||||
{name:'mcp_fixture_lookup',description:'MCP lookup',parameters:{type:'object'}}],
|
||||
messages:[{role:'user',content:`Question\n\n${injected}`},
|
||||
{role:'assistant',tool_calls:[{id:'call',function:{name:'mcp_fixture_lookup',arguments:'{}'}}]},
|
||||
{role:'tool',tool_call_id:'call',content:'returned MCP contents'}]}
|
||||
const counted=estimateContextInput(input)
|
||||
assert.ok(counted.counts.system>0&&counted.counts.skills>0&&counted.counts.tools>0&&counted.counts.conversation>0&&counted.counts.mcp>0)
|
||||
assert.equal(Object.values(counted.counts).reduce((a,b)=>a+b,0),counted.inputTokens)
|
||||
assert.equal(estimateContextInput({messages:[{role:'user',content:skill}]}).counts.skills,0,'ordinary user text is not relabeled by a tag pattern')
|
||||
assert.equal(estimateContextInput({messages:[{role:'user',content:[{type:'image_url',image_url:{url:'data:image/png;base64,secret'}}]}]}).partial,true,'images are partial, not base64 tokens')
|
||||
const breakdown:ContextUsageBreakdown={...counted,requestId:'run',sequence:1,sampledAt:1,method:'utf8-estimate',scope:'provider-request'}
|
||||
let assistant:ChatMessage={id:'a',role:'assistant',content:'',createdAt:0,requestId:'run'}
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'usage',usage:{inputTokens:9999,outputTokens:1,totalTokens:10000}})
|
||||
assert.equal(assistant.contextUsage?.usage.inputTokens,9999)
|
||||
assert.equal(assistant.contextBreakdown?.inputTokens,counted.inputTokens,'classification is not rescaled to provider usage')
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
|
||||
assert.equal(assistant.contextUsage?.usage.inputTokens,9999,'a repeated old observer frame cannot clear newer usage')
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown:{...breakdown,sequence:2}})
|
||||
assert.equal(assistant.contextUsage,undefined,'a new prepared request invalidates the previous provider total')
|
||||
assert.equal(contextInfoFromMessages([{...assistant,contextBreakdown:breakdown},{id:'pending',role:'user',content:'next',createdAt:5}]).status,'previous',
|
||||
'an unmatched latest user message marks any retained statistic as previous')
|
||||
assert.equal(contextInfoFromMessages([{...assistant,requestId:'different',contextBreakdown:breakdown}]).breakdown,undefined,
|
||||
'a stored classification for a different request is not reused')
|
||||
|
||||
// The fallback observes the final body for both supported provider protocols.
|
||||
const originalFetch=globalThis.fetch
|
||||
try {
|
||||
for(const responses of [false,true]) {
|
||||
let observed:Record<string,unknown>|undefined,transmitted:Record<string,unknown>|undefined
|
||||
globalThis.fetch=(async(_url,options)=>{
|
||||
transmitted=JSON.parse(String(options?.body))
|
||||
const payload=responses
|
||||
? 'data: '+JSON.stringify({type:'response.completed',response:{id:'reply',status:'completed',output:[],usage:{input_tokens:10,output_tokens:1,total_tokens:11}}})+'\n\n'
|
||||
: 'data: '+JSON.stringify({choices:[{index:0,delta:{content:'done'},finish_reason:'stop'}],usage:{prompt_tokens:10,completion_tokens:1,total_tokens:11}})+'\n\ndata: [DONE]\n\n'
|
||||
return new Response(payload,{status:200,headers:{'content-type':'text/event-stream'}})
|
||||
}) as typeof fetch
|
||||
const gateway=new Gateway({baseUrl:responses?'http://127.0.0.1:1/v1/responses':'http://127.0.0.1:1/v1',apiKey:'',authMode:'none',model:'fixture',temperature:0,maxToolRounds:1},body=>{observed=structuredClone(body)})
|
||||
for await(const _delta of gateway.stream({messages:[{role:'system',content:'system'},{role:'user',content:'hello'}],tools:[{type:'function',function:{name:'unused',description:'definition',parameters:{type:'object'}}}]})) {/* fully consume */}
|
||||
assert.deepEqual(observed,transmitted,'observer sees the exact transmitted body without changing it')
|
||||
const body=observed as any
|
||||
assert.ok(estimateContextInput({messages:body.messages??body.input,systemPrompt:body.instructions,tools:body.tools}).counts.tools>0)
|
||||
}
|
||||
}finally{globalThis.fetch=originalFetch}
|
||||
console.log('context-usage-regression: all checks passed')
|
||||
@@ -1,931 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import type { ChatMessage, ToolCallRecord } from '../src/shared/types'
|
||||
|
||||
interface TimelineEntry {
|
||||
kind: 'branch' | 'prompt'
|
||||
sessionId: string
|
||||
entryId?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
userData: string
|
||||
home: string
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>
|
||||
dialogPaths: string[]
|
||||
nativeMessages: Map<string, Array<{ entryId: string; text: string }>>
|
||||
failBranchIds: Set<string>
|
||||
timeline: TimelineEntry[]
|
||||
pendingPrompts: Array<{ sessionId: string; complete: () => void }>
|
||||
holdSessions: Set<string>
|
||||
activePrompts: number
|
||||
maxActivePrompts: number
|
||||
nextNativeId: number
|
||||
speechStatus: number
|
||||
openPathError?: string
|
||||
failStartTurn?: boolean
|
||||
nativeFailurePath?: string
|
||||
speechRequests: Array<{ path: string; authorization: string | undefined; contentType: string | undefined; body: string }>
|
||||
events: unknown[]
|
||||
}
|
||||
|
||||
interface TestBundle {
|
||||
registerIpc(getWindow: () => unknown): void
|
||||
disposeOmpKernels(): Promise<void>
|
||||
sessions: {
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
getMessages(sessionId: string): Promise<ChatMessage[]>
|
||||
getPendingNativeBranch(sessionId: string): Promise<string | undefined>
|
||||
setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise<void>
|
||||
}
|
||||
attachments: {
|
||||
getAttachment(id: string): Promise<{ path?: string; name: string }>
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_STATE_KEY = '__YCODE_CONVERSATION_IPC_REGRESSION__'
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean | Promise<boolean>, message: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 250; attempt++) {
|
||||
if (await predicate()) return
|
||||
await wait(10)
|
||||
}
|
||||
assert.fail(`Timed out: ${message}`)
|
||||
}
|
||||
|
||||
async function waitForAssistant(
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>,
|
||||
sessionId: string,
|
||||
predicate: (message: ChatMessage) => boolean = (message) => message.status === 'done'
|
||||
): Promise<ChatMessage[]> {
|
||||
for (let attempt = 0; attempt < 250; attempt++) {
|
||||
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
|
||||
const last = messages.findLast((message) => message.role === 'assistant')
|
||||
// In-memory messages can reach the terminal status before the atomic write
|
||||
// and terminal broadcast finish. Wait for the real turn barrier as well.
|
||||
if (last && predicate(last) && !await invoke('chat:active-run', sessionId)) return messages
|
||||
await wait(10)
|
||||
}
|
||||
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
|
||||
assert.fail(`Timed out waiting for assistant state: ${JSON.stringify(messages.at(-1))}`)
|
||||
}
|
||||
|
||||
function assistant(messages: ChatMessage[]): ChatMessage {
|
||||
const found = messages.findLast((message) => message.role === 'assistant')
|
||||
assert.ok(found, 'expected an assistant message')
|
||||
return found
|
||||
}
|
||||
|
||||
function invokeFor(state: TestState) {
|
||||
return (channel: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const handler = state.handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return Promise.resolve().then(() => handler({}, ...args))
|
||||
}
|
||||
}
|
||||
|
||||
function message(id: string, role: 'user' | 'assistant', content: string, extras: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return { id, role, content, createdAt: Date.now(), status: 'done', ...extras }
|
||||
}
|
||||
|
||||
function seedNativeHistory(state: TestState, sessionId: string, entries: Array<[string, string]>): void {
|
||||
state.nativeMessages.set(sessionId, entries.map(([entryId, text]) => ({ entryId, text })))
|
||||
}
|
||||
|
||||
function nativePath(userData: string, sessionId: string): string {
|
||||
return join(userData, 'omp-sessions', `session-${encodeURIComponent(sessionId)}.jsonl`)
|
||||
}
|
||||
|
||||
async function buildBundle(tempRoot: string, state: TestState, name: string): Promise<TestBundle> {
|
||||
const ipcEntry = join(process.cwd(), 'src', 'main', 'ipc.ts')
|
||||
const sessionsEntry = join(process.cwd(), 'src', 'main', 'sessions.ts')
|
||||
const attachmentsEntry = join(process.cwd(), 'src', 'main', 'attachments.ts')
|
||||
const outfile = join(tempRoot, `${name}.mjs`)
|
||||
const stateKey = JSON.stringify(TEST_STATE_KEY)
|
||||
await build({
|
||||
stdin: {
|
||||
contents: [
|
||||
`export { registerIpc, disposeOmpKernels } from ${JSON.stringify(ipcEntry)};`,
|
||||
`export * as sessions from ${JSON.stringify(sessionsEntry)};`,
|
||||
`export * as attachments from ${JSON.stringify(attachmentsEntry)};`
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: `${name}.ts`,
|
||||
loader: 'ts'
|
||||
},
|
||||
outfile,
|
||||
bundle: true,
|
||||
external: ['node-pty'],
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
testTerminalHostPlugin,
|
||||
{
|
||||
name: 'conversation-ipc-electron-and-omp-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^\.\/sessions$/ }, () => ({ path: 'sessions', namespace: 'mock-session-start' }))
|
||||
pluginBuild.onResolve({ filter: /sessions\.ts$/, namespace: 'mock-session-start' }, () => ({ path: sessionsEntry, namespace: 'file' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-session-start' }, () => ({
|
||||
loader: 'js', contents: `
|
||||
import * as real from ${JSON.stringify(sessionsEntry)};
|
||||
export * from ${JSON.stringify(sessionsEntry)};
|
||||
export async function startTurn(...args) {
|
||||
const state = globalThis[${stateKey}];
|
||||
if (state.failStartTurn) {
|
||||
state.failStartTurn = false;
|
||||
if (state.nativeFailurePath) await (await import('node:fs/promises')).writeFile(state.nativeFailurePath, 'changed-native-leaf');
|
||||
throw new Error('mock turn persistence failure');
|
||||
}
|
||||
return real.startTurn(...args);
|
||||
}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis[${stateKey}];
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : state.userData,
|
||||
getName: () => 'YCode', getVersion: () => '0.1.0',
|
||||
isPackaged: false,
|
||||
isReady: () => true,
|
||||
getAppPath: () => state.userData,
|
||||
getLoginItemSettings: () => ({ openAtLogin: false }),
|
||||
setLoginItemSettings: () => {}
|
||||
};
|
||||
export const safeStorage = {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString()
|
||||
};
|
||||
export const ipcMain = { handle: (name, handler) => state.handlers.set(name, handler) };
|
||||
export const dialog = {
|
||||
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
|
||||
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
|
||||
};
|
||||
export const shell = { openPath: async () => state.openPathError || '', openExternal: async () => {} };
|
||||
export const powerSaveBlocker = {
|
||||
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
|
||||
stop: () => true,
|
||||
isStarted: () => (state.powerBlockers ?? 0) > 0
|
||||
};
|
||||
export class BrowserWindow {}
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
const state = globalThis[${stateKey}];
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor(_exe, _workspace, _profile, sessionPath) {
|
||||
super();
|
||||
this.sessionId = decodeURIComponent(basename(sessionPath).replace(/^session-/, '').replace(/\\.jsonl$/, ''));
|
||||
if (!state.nativeMessages.has(this.sessionId)) state.nativeMessages.set(this.sessionId, []);
|
||||
}
|
||||
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
|
||||
setThinkingLevel() { return Promise.resolve(); }
|
||||
send(command) {
|
||||
if (command.type === 'get_branch_messages') {
|
||||
return Promise.resolve({ type: 'response', success: true, data: { messages: [...(state.nativeMessages.get(this.sessionId) || [])] } });
|
||||
}
|
||||
if (command.type === 'branch') {
|
||||
const entryId = String(command.entryId || '');
|
||||
state.timeline.push({ kind: 'branch', sessionId: this.sessionId, entryId });
|
||||
if (state.failBranchIds.has(entryId)) return Promise.resolve({ type: 'response', success: false, error: 'mock branch failure' });
|
||||
const entries = state.nativeMessages.get(this.sessionId) || [];
|
||||
const index = entries.findIndex((entry) => entry.entryId === entryId);
|
||||
if (index < 0) return Promise.resolve({ type: 'response', success: false, error: 'Invalid entry ID for branching' });
|
||||
state.nativeMessages.set(this.sessionId, entries.slice(0, index));
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true, data: {} });
|
||||
}
|
||||
branch(entryId) { return this.send({ type: 'branch', entryId }); }
|
||||
prompt(text) {
|
||||
state.timeline.push({ kind: 'prompt', sessionId: this.sessionId, text });
|
||||
const entryId = 'native-prompt-' + (++state.nextNativeId);
|
||||
const entries = state.nativeMessages.get(this.sessionId) || [];
|
||||
entries.push({ entryId, text });
|
||||
state.nativeMessages.set(this.sessionId, entries);
|
||||
state.activePrompts++;
|
||||
state.maxActivePrompts = Math.max(state.maxActivePrompts, state.activePrompts);
|
||||
const complete = () => {
|
||||
state.activePrompts--;
|
||||
this.emit('event', { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'reply:' + text } });
|
||||
this.emit('event', { type: 'agent_end', yielded: true });
|
||||
};
|
||||
if (state.holdSessions.has(this.sessionId)) state.pendingPrompts.push({ sessionId: this.sessionId, complete });
|
||||
else setImmediate(complete);
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
abort() {
|
||||
setImmediate(() => this.emit('event', { type: 'prompt_result', status: 'aborted' }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
respondToHostTool() { return Promise.resolve(); }
|
||||
dispose() { return Promise.resolve(); }
|
||||
}
|
||||
export const detectOmpPath = () => 'mock-omp';
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-agent-loop' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-agent-loop' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `export async function runTurn() { throw new Error('unexpected non-OMP fallback'); }`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
|
||||
loader: 'js', contents: 'export class Gateway { constructor() {} }'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
return await import(pathToFileURL(outfile).href) as TestBundle
|
||||
}
|
||||
|
||||
function register(bundle: TestBundle, state: TestState): (channel: string, ...args: unknown[]) => Promise<unknown> {
|
||||
state.handlers.clear()
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: (channel: string, event: unknown) => { if (channel === 'chat:event') state.events.push(event) } }
|
||||
}
|
||||
bundle.registerIpc(() => mainWindow)
|
||||
return invokeFor(state)
|
||||
}
|
||||
|
||||
async function testConcurrency(bundle: TestBundle, state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<void> {
|
||||
const first = await invoke('session:create', 'concurrent one') as { id: string }
|
||||
const second = await invoke('session:create', 'concurrent two') as { id: string }
|
||||
state.holdSessions.add(first.id)
|
||||
state.holdSessions.add(second.id)
|
||||
|
||||
const firstSend = await invoke('chat:send', { sessionId: first.id, text: 'concurrent-first' }) as {
|
||||
modelId?: string
|
||||
modelName?: string
|
||||
}
|
||||
await assert.rejects(invoke('chat:send', { sessionId: first.id, text: 'must-not-append' }), /正在运行/)
|
||||
await invoke('chat:send', { sessionId: second.id, text: 'concurrent-second' })
|
||||
|
||||
await waitUntil(() => state.pendingPrompts.filter((item) => item.sessionId === first.id || item.sessionId === second.id).length === 2,
|
||||
'both sessions should reach OMP concurrently')
|
||||
const firstMessages = await invoke('session:messages', first.id) as ChatMessage[]
|
||||
assert.equal(firstSend.modelId, assistant(firstMessages).modelId,
|
||||
'chat:send returns the frozen model ID used by the persisted assistant message')
|
||||
assert.equal(firstSend.modelName, assistant(firstMessages).modelName,
|
||||
'chat:send returns the frozen model name used by the persisted assistant message')
|
||||
assert.deepEqual(firstMessages.filter((item) => item.role === 'user').map((item) => item.content), ['concurrent-first'],
|
||||
'a duplicate same-session send must not append a second user message')
|
||||
assert.ok(state.maxActivePrompts >= 2, 'different sessions should be able to run in parallel')
|
||||
|
||||
const held = state.pendingPrompts.splice(0)
|
||||
state.holdSessions.clear()
|
||||
held.forEach((item) => item.complete())
|
||||
const doneFirst = await waitForAssistant(invoke, first.id)
|
||||
const doneSecond = await waitForAssistant(invoke, second.id)
|
||||
assert.equal(assistant(doneFirst).status, 'done')
|
||||
assert.equal(assistant(doneSecond).status, 'done')
|
||||
const metadata = (await invoke('session:list')) as Array<{ id: string; title: string }>
|
||||
assert.equal(metadata.find((item) => item.id === first.id)?.title, 'concurrent-first', 'first send should set its title')
|
||||
}
|
||||
|
||||
async function seedActionHistory(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
action: 'edit' | 'resend' | 'regenerate'
|
||||
): Promise<{ sessionId: string; targetUserId: string; targetAssistantId: string }> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', `action-${action}`) as { id: string }
|
||||
const targetUserId = `${action}-user-target`
|
||||
const targetAssistantId = `${action}-assistant-target`
|
||||
const history = [
|
||||
message(`${action}-user-before`, 'user', 'first prompt', { nativeEntryId: `${action}-native-before` }),
|
||||
message(`${action}-assistant-before`, 'assistant', 'first reply'),
|
||||
message(targetUserId, 'user', 'old prompt', { nativeEntryId: `${action}-native-target` }),
|
||||
message(targetAssistantId, 'assistant', 'old reply')
|
||||
]
|
||||
for (const item of history) await bundle.sessions.appendMessage(session.id, item)
|
||||
seedNativeHistory(state, session.id, [
|
||||
[`${action}-native-before`, 'first prompt'],
|
||||
[`${action}-native-target`, 'old prompt']
|
||||
])
|
||||
return { sessionId: session.id, targetUserId, targetAssistantId }
|
||||
}
|
||||
|
||||
async function testActionBranch(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
action: 'edit' | 'resend' | 'regenerate'
|
||||
): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const seeded = await seedActionHistory(bundle, state, action)
|
||||
const timelineStart = state.timeline.length
|
||||
const fromMessageId = action === 'regenerate' ? seeded.targetAssistantId : seeded.targetUserId
|
||||
await invoke('chat:send', {
|
||||
sessionId: seeded.sessionId,
|
||||
text: `${action}-replacement`,
|
||||
action,
|
||||
fromMessageId
|
||||
})
|
||||
const messages = await waitForAssistant(invoke, seeded.sessionId)
|
||||
const activity = state.timeline.slice(timelineStart).filter((item) => item.sessionId === seeded.sessionId)
|
||||
const branchIndex = activity.findIndex((item) => item.kind === 'branch' && item.entryId === `${action}-native-target`)
|
||||
const promptIndex = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === `${action}-replacement`)
|
||||
assert.ok(branchIndex >= 0 && promptIndex > branchIndex, `${action} must branch by native entryId before prompting`)
|
||||
assert.equal(activity[promptIndex].text, [`${action}-replacement`, messages[2].capabilityContext].filter(Boolean).join('\n\n'),
|
||||
'native prompt must include the exact persisted turn context without changing the user text')
|
||||
assert.match(activity[promptIndex].text ?? '', /<ycode-agent-mode mode="craft">/, 'native prompt declares the frozen craft mode')
|
||||
assert.equal(messages.length, 4, `${action} should keep the old prefix and replace the selected suffix`)
|
||||
assert.deepEqual(messages.slice(0, 2).map((item) => item.content), ['first prompt', 'first reply'])
|
||||
assert.equal(messages[2].content, `${action}-replacement`)
|
||||
assert.ok(messages[2].nativeEntryId?.startsWith('native-prompt-'), 'new user message should receive the returned native entryId')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
|
||||
'successful native entry lookup should clear the pending branch marker')
|
||||
|
||||
const archiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(seeded.sessionId))
|
||||
const archiveFiles = await readdir(archiveDirectory)
|
||||
assert.equal(archiveFiles.length, 1, `${action} should archive the replaced branch`)
|
||||
const archive = JSON.parse(await readFile(join(archiveDirectory, archiveFiles[0]), 'utf8')) as {
|
||||
reason: string
|
||||
session: { messages: ChatMessage[] }
|
||||
}
|
||||
assert.equal(archive.reason, action)
|
||||
assert.equal(archive.session.messages.length, 4, 'archive should preserve the full pre-action conversation')
|
||||
}
|
||||
|
||||
async function testActionBranchFailure(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const seeded = await seedActionHistory(bundle, state, 'edit')
|
||||
const before = await bundle.sessions.getMessages(seeded.sessionId)
|
||||
state.failBranchIds.add('edit-native-target')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: seeded.sessionId,
|
||||
text: 'must-not-append-on-branch-failure',
|
||||
action: 'edit',
|
||||
fromMessageId: seeded.targetUserId
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-native-target')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(seeded.sessionId), before,
|
||||
'failed native branching must not archive or append a replacement user message')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
|
||||
'failed action branch should restore the prior marker')
|
||||
}
|
||||
|
||||
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
|
||||
const rolledPath = join(workspace, 'edit-rollback.txt')
|
||||
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
|
||||
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
|
||||
const rolledMessages = await waitForAssistant(invoke, rolledSession)
|
||||
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
|
||||
'edit submit must roll the artifact back to its version before the message')
|
||||
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
|
||||
assert.equal(rolledMessages[0].content, 'edited prompt')
|
||||
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
|
||||
|
||||
// 分支失败:撤销文件回退,保持会话不变。
|
||||
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
|
||||
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
|
||||
const failedBefore = await bundle.sessions.getMessages(failedSession)
|
||||
state.failBranchIds.add('edit-rollback-failed-native')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-rollback-failed-native')
|
||||
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
|
||||
'failed edit must leave the persisted conversation untouched')
|
||||
|
||||
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
|
||||
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
|
||||
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
|
||||
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
|
||||
const plainMessages = await waitForAssistant(invoke, plainSession.id)
|
||||
assert.equal(plainMessages.length, 2)
|
||||
assert.equal(plainMessages[0].content, 'edited plain')
|
||||
|
||||
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
|
||||
const blockedPath = join(workspace, 'edit-blocked.txt')
|
||||
await writeFile(blockedPath, 'after', 'utf8')
|
||||
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
|
||||
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
|
||||
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
|
||||
}), /无法完整回滚/)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
|
||||
'rejected edit must leave history untouched')
|
||||
|
||||
await invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edit without rolling files back', action: 'edit',
|
||||
fromMessageId: 'edit-blocked-user', rollbackArtifacts: false
|
||||
})
|
||||
await waitForAssistant(invoke, blockedSession.id)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'explicit skip must preserve current files even for non-reversible tools')
|
||||
|
||||
const keptPath = join(workspace, 'edit-keep-latest.txt')
|
||||
const keptSession = await seedWriteHistory(bundle, state, 'edit-keep-latest', keptPath, 'keep-native', 'after')
|
||||
await invoke('chat:send', { sessionId: keptSession, text: 'use latest files', action: 'edit',
|
||||
fromMessageId: 'edit-keep-latest-user', rollbackArtifacts: false })
|
||||
await waitForAssistant(invoke, keptSession)
|
||||
assert.equal(await readFile(keptPath, 'utf8'), 'after', 'skipping artifact rollback retains the latest version')
|
||||
|
||||
const persistPath = join(workspace, 'edit-turn-persist-failed.txt')
|
||||
const persistSession = await seedWriteHistory(bundle, state, 'edit-persist-failed', persistPath, 'edit-persist-native', 'after')
|
||||
const persistHistory = structuredClone(await bundle.sessions.getMessages(persistSession))
|
||||
await mkdir(join(state.userData, 'omp-sessions'), { recursive: true })
|
||||
state.nativeFailurePath = nativePath(state.userData, persistSession)
|
||||
await writeFile(state.nativeFailurePath, 'original-native-transcript', 'utf8')
|
||||
state.failStartTurn = true
|
||||
await assert.rejects(invoke('chat:send', { sessionId: persistSession, text: 'failed save', action: 'edit',
|
||||
fromMessageId: 'edit-persist-failed-user' }), /turn persistence failure/)
|
||||
assert.equal(await readFile(persistPath, 'utf8'), 'after', 'turn persistence failure must undo the already completed artifact rollback')
|
||||
assert.equal(await readFile(state.nativeFailurePath, 'utf8'), 'original-native-transcript', 'failed edited turn must restore the native resume transcript too')
|
||||
state.nativeFailurePath = undefined
|
||||
assert.deepEqual(await bundle.sessions.getMessages(persistSession), persistHistory, 'failed edited turn persistence must preserve prior history')
|
||||
}
|
||||
|
||||
async function testSenderRoutingAndPreflight(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', 'secondary window') as { id: string }
|
||||
const events: Array<{ runId: string; type: string }> = []
|
||||
let closed = false
|
||||
const handler = state.handlers.get('chat:send')!
|
||||
const sender = { isDestroyed: () => { if (closed) throw new Error('WebContents destroyed'); return false },
|
||||
send: (channel: string, event: { runId: string; type: string }) => { if (channel === 'chat:event') events.push(event) } }
|
||||
const sent = await handler({ sender }, { sessionId: session.id, text: 'secondary request' }) as { runId: string }
|
||||
await waitForAssistant(invoke, session.id)
|
||||
assert.ok(events.some((event) => event.type === 'text'), 'the requesting secondary renderer receives text deltas')
|
||||
assert.ok(events.some((event) => event.type === 'done'), 'the requesting secondary renderer receives its terminal event')
|
||||
assert.ok(state.events.some((event) => (event as { runId?: string }).runId === sent.runId), 'the main renderer also observes the shared turn')
|
||||
const streamSequences = events.map((event) => (event as { sequence?: number }).sequence)
|
||||
assert.ok(streamSequences.every((sequence, index) => typeof sequence === 'number' && (index === 0 || sequence > (streamSequences[index - 1] ?? -1))),
|
||||
'shared events must carry a strictly increasing run sequence')
|
||||
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /已有消息/,
|
||||
'empty-session cleanup cannot remove a conversation populated by another renderer')
|
||||
|
||||
state.holdSessions.add(session.id)
|
||||
await handler({ sender }, { sessionId: session.id, text: 'finish after window closes' })
|
||||
await waitUntil(() => state.pendingPrompts.some((item) => item.sessionId === session.id), 'secondary held prompt')
|
||||
const active = await invoke('chat:active-run', session.id) as { runId: string; messages: ChatMessage[]; sequence: number }
|
||||
assert.ok(active?.runId && active.messages.at(-1)?.status === 'streaming', 'a reopened renderer can obtain the current run and exact live messages')
|
||||
assert.equal(active.messages.filter((message) => message.role === 'user').length, 2, 'live snapshots include the complete history exactly once')
|
||||
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /正在运行/,
|
||||
'cleanup must reject a running session without cancelling its turn')
|
||||
closed = true
|
||||
const before = events.length
|
||||
const held = state.pendingPrompts.find((item) => item.sessionId === session.id)!
|
||||
state.pendingPrompts = state.pendingPrompts.filter((item) => item !== held)
|
||||
state.holdSessions.delete(session.id)
|
||||
held.complete()
|
||||
await waitForAssistant(invoke, session.id)
|
||||
assert.equal(events.length, before, 'closing the requester cannot interrupt persistence or send into a destroyed renderer')
|
||||
|
||||
const legacy = await invoke('session:create', 'legacy edit') as { id: string }
|
||||
await bundle.sessions.appendMessage(legacy.id, message('legacy-user', 'user', 'imported input'))
|
||||
await bundle.sessions.appendMessage(legacy.id, message('legacy-answer', 'assistant', 'imported answer'))
|
||||
const denied = await invoke('chat:inspect-edit', legacy.id, 'legacy-user') as { editable: boolean; reason?: string }
|
||||
assert.equal(denied.editable, false, 'legacy native branches must be checked before opening an editor')
|
||||
assert.match(denied.reason ?? '', /原生分支/)
|
||||
assert.deepEqual(await invoke('chat:inspect-edit', session.id, (await bundle.sessions.getMessages(session.id))[0].id), { editable: true })
|
||||
|
||||
const keepPath = join(workspace, 'rewind-keep.txt')
|
||||
const keepSession = await seedWriteHistory(bundle, state, 'rewind-keep', keepPath, 'rewind-keep-native', 'after')
|
||||
await invoke('chat:revert', keepSession, 'rewind-keep-user', { rollbackArtifacts: false })
|
||||
assert.equal(await readFile(keepPath, 'utf8'), 'after', 'conversation-only rewind preserves artifact contents')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(keepSession), [], 'conversation-only rewind still persists the truncated history')
|
||||
await invoke('session:delete', keepSession, { onlyIfEmpty: true })
|
||||
assert.ok(!(await invoke('session:list') as Array<{ id: string }>).some((item) => item.id === keepSession), 'atomic cleanup removes a genuinely empty session')
|
||||
|
||||
state.openPathError = 'mock folder cannot be opened'
|
||||
await assert.rejects(invoke('general:openFolder', 'workspace'), /mock folder cannot be opened/)
|
||||
state.openPathError = undefined
|
||||
}
|
||||
|
||||
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
for (const nativeAccepted of [false, true]) {
|
||||
const session = await invoke('session:create', 'interrupted-resend') as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message('prefix-user', 'user', 'earlier', { nativeEntryId: 'prefix-native' }))
|
||||
await bundle.sessions.appendMessage(session.id, message('prefix-assistant', 'assistant', 'earlier reply'))
|
||||
await bundle.sessions.appendMessage(session.id, message('interrupted-user', 'user', 'interrupted prompt'))
|
||||
await bundle.sessions.appendMessage(session.id, message('interrupted-assistant', 'assistant', 'partial', { status: 'cancelled', interrupted: true }))
|
||||
seedNativeHistory(state, session.id, [['prefix-native', 'earlier'], ...(nativeAccepted ? [['unbound-native', 'interrupted prompt'] as [string, string]] : [])])
|
||||
const start = state.timeline.length
|
||||
await invoke('chat:send', { sessionId: session.id, text: 'interrupted prompt', action: 'resend', fromMessageId: 'interrupted-user' })
|
||||
const result = await waitForAssistant(invoke, session.id)
|
||||
assert.equal(result.length, 4)
|
||||
assert.equal(result[2].content, 'interrupted prompt')
|
||||
const branches = state.timeline.slice(start).filter((entry) => entry.kind === 'branch')
|
||||
assert.equal(branches.length, nativeAccepted ? 1 : 0)
|
||||
if (nativeAccepted) assert.equal(branches[0].entryId, 'unbound-native', 'recover the accepted native user without truncating its preceding context')
|
||||
assert.ok(result[2].nativeEntryId?.startsWith('native-prompt-'))
|
||||
}
|
||||
}
|
||||
|
||||
function writeTool(id: string, path: string, oldContent: string | null, newContent: string): ToolCallRecord {
|
||||
return {
|
||||
id,
|
||||
name: 'write_file',
|
||||
args: { path },
|
||||
argsRaw: JSON.stringify({ path }),
|
||||
status: 'ok',
|
||||
output: 'write complete',
|
||||
result: { path, oldContent, newContent }
|
||||
}
|
||||
}
|
||||
|
||||
async function seedWriteHistory(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
title: string,
|
||||
path: string,
|
||||
targetEntryId: string,
|
||||
currentFile: string
|
||||
): Promise<string> {
|
||||
await writeFile(path, currentFile, 'utf8')
|
||||
const session = await invokeFor(state)('session:create', title) as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message(`${title}-user`, 'user', 'write prompt', { nativeEntryId: targetEntryId }))
|
||||
await bundle.sessions.appendMessage(session.id, message(`${title}-assistant`, 'assistant', 'write answer', {
|
||||
tools: [writeTool(`${title}-write`, path, 'before', 'after')]
|
||||
}))
|
||||
seedNativeHistory(state, session.id, [[targetEntryId, 'write prompt']])
|
||||
return session.id
|
||||
}
|
||||
|
||||
async function testFeedbackAndRevert(bundle: TestBundle, state: TestState, workspace: string): Promise<{
|
||||
markerSessionId: string
|
||||
markerEntryId: string
|
||||
}> {
|
||||
const invoke = invokeFor(state)
|
||||
const feedbackSession = await invoke('session:create', 'feedback') as { id: string }
|
||||
const feedbackUser = message('feedback-user', 'user', 'feedback request')
|
||||
const feedbackAssistant = message('feedback-assistant', 'assistant', 'feedback response')
|
||||
await bundle.sessions.appendMessage(feedbackSession.id, feedbackUser)
|
||||
await bundle.sessions.appendMessage(feedbackSession.id, feedbackAssistant)
|
||||
await invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'like')
|
||||
assert.equal((await bundle.sessions.getMessages(feedbackSession.id))[1].feedback, 'like')
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackUser.id, 'dislike'), /助手消息/)
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, 'missing', 'like'), /助手消息/)
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'bad'), /反馈值无效/)
|
||||
|
||||
const conflictPath = join(workspace, 'conflict.txt')
|
||||
const conflictId = await seedWriteHistory(bundle, state, 'conflict-revert', conflictPath, 'native-conflict-target', 'external edit')
|
||||
const conflictBefore = await bundle.sessions.getMessages(conflictId)
|
||||
await assert.rejects(invoke('chat:revert', conflictId, 'conflict-revert-user'), /外部修改/)
|
||||
assert.equal(await readFile(conflictPath, 'utf8'), 'external edit', 'revert conflict must preserve the externally edited file')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(conflictId), conflictBefore, 'conflict should leave history intact')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(conflictId), undefined)
|
||||
|
||||
const rejectedPath = join(workspace, 'branch-failure.txt')
|
||||
const rejectedId = await seedWriteHistory(bundle, state, 'failed-revert', rejectedPath, 'native-failed-revert', 'after')
|
||||
state.failBranchIds.add('native-failed-revert')
|
||||
await assert.rejects(invoke('chat:revert', rejectedId, 'failed-revert-user'), /恢复失败/)
|
||||
state.failBranchIds.delete('native-failed-revert')
|
||||
assert.equal(await readFile(rejectedPath, 'utf8'), 'after', 'failed native branch must undo the file restore')
|
||||
assert.equal((await bundle.sessions.getMessages(rejectedId)).length, 2, 'failed native branch must preserve history')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(rejectedId), undefined)
|
||||
|
||||
const readOnlyId = await invoke('session:create', 'read-only-revert') as { id: string }
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only', { nativeEntryId: 'native-read-only-target' }))
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', {
|
||||
tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, readOnlyId.id, [['native-read-only-target', 'read only']])
|
||||
// 原版 rewind 的 no-version 变体:没有产物版本时只回退对话内容,产物保持不变。
|
||||
const readOnlyResult = await invoke('chat:revert', readOnlyId.id, 'read-only-user') as { messages: unknown[] }
|
||||
assert.deepEqual(readOnlyResult.messages, [], 'conversation-only rewind should truncate to before the message')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(readOnlyId.id), [], 'conversation-only rewind should persist the truncated prefix')
|
||||
|
||||
const unknownPath = join(workspace, 'unknown-write.txt')
|
||||
await writeFile(unknownPath, 'after', 'utf8')
|
||||
const unknownId = await invoke('session:create', 'unknown-write-revert') as { id: string }
|
||||
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-user', 'user', 'unknown write', {
|
||||
nativeEntryId: 'native-unknown-write'
|
||||
}))
|
||||
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-assistant', 'assistant', 'unknown', {
|
||||
tools: [{
|
||||
id: 'unknown-write-tool', name: 'delete_file', args: {}, argsRaw: '{}', status: 'ok',
|
||||
result: { path: unknownPath, oldContent: 'before', newContent: 'after' }
|
||||
}]
|
||||
}))
|
||||
await assert.rejects(invoke('chat:revert', unknownId.id, 'unknown-write-user'), /无法完整回滚/)
|
||||
assert.equal(await readFile(unknownPath, 'utf8'), 'after', 'unrecognized writes must not be treated as reversible')
|
||||
|
||||
const markerPath = join(workspace, 'persist-revert.txt')
|
||||
const markerSessionId = await seedWriteHistory(bundle, state, 'pending-revert', markerPath, 'native-revert-target', 'after')
|
||||
await invoke('chat:revert', markerSessionId, 'pending-revert-user')
|
||||
assert.equal(await readFile(markerPath, 'utf8'), 'before', 'known complete host write should be restored')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(markerSessionId), [], 'successful revert should truncate the active history prefix')
|
||||
const markerEntryId = await bundle.sessions.getPendingNativeBranch(markerSessionId)
|
||||
assert.equal(markerEntryId, 'native-revert-target', 'successful revert should persist a replay marker')
|
||||
const revertArchiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(markerSessionId))
|
||||
const revertArchiveFiles = await readdir(revertArchiveDirectory)
|
||||
assert.equal(revertArchiveFiles.length, 1, 'revert should archive its abandoned branch')
|
||||
const revertArchive = JSON.parse(await readFile(join(revertArchiveDirectory, revertArchiveFiles[0]), 'utf8')) as {
|
||||
reason: string
|
||||
session: { messages: ChatMessage[] }
|
||||
}
|
||||
assert.equal(revertArchive.reason, 'revert')
|
||||
assert.equal(revertArchive.session.messages.length, 2, 'revert archive should preserve the messages removed from the active history')
|
||||
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
|
||||
sessions: Array<{ id: string; pendingNativeBranchId?: string }>
|
||||
}
|
||||
assert.equal(stored.sessions.find((item) => item.id === markerSessionId)?.pendingNativeBranchId, markerEntryId,
|
||||
'pending native branch marker should be present on disk')
|
||||
return { markerSessionId, markerEntryId: markerEntryId! }
|
||||
}
|
||||
|
||||
async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const pickedPath = join(tempRoot, 'picked.txt')
|
||||
await writeFile(pickedPath, 'picked file content', 'utf8')
|
||||
state.dialogPaths.push(pickedPath)
|
||||
const picked = await invoke('attachments:pick') as Array<{ id: string; name: string; path?: string; sourcePath?: string; size: number }>
|
||||
assert.equal(picked.length, 1)
|
||||
assert.equal(picked[0].name, 'picked.txt')
|
||||
assert.equal(picked[0].size, Buffer.byteLength('picked file content'))
|
||||
assert.ok(picked[0].path && (await readFile(picked[0].path, 'utf8')) === 'picked file content',
|
||||
'picker should stage a real copy in user data')
|
||||
assert.equal(picked[0].sourcePath, pickedPath, 'display source path stays separate from the managed attachment copy')
|
||||
|
||||
const staged = await invoke('attachments:stage', {
|
||||
name: '../safe-note.txt', mimeType: 'text/plain', dataBase64: Buffer.from('attachment body').toString('base64')
|
||||
}) as { id: string; name: string; path?: string; size: number }
|
||||
assert.equal(staged.name, 'safe-note.txt', 'staging should discard path components from the supplied filename')
|
||||
assert.equal(staged.size, Buffer.byteLength('attachment body'))
|
||||
assert.equal((await bundle.attachments.getAttachment(staged.id)).name, 'safe-note.txt')
|
||||
assert.equal(await readFile(staged.path!, 'utf8'), 'attachment body')
|
||||
await assert.rejects(invoke('resources:read-text', staged.path), /超出工作目录/)
|
||||
const preview = await invoke('attachments:preview', staged.id) as { kind: string; content: string; path: string }
|
||||
assert.equal(preview.kind, 'text')
|
||||
assert.equal(preview.content, 'attachment body', 'registered attachment preview must work outside the current workspace')
|
||||
assert.equal(await invoke('resources:read-text', preview.path), 'attachment body', 'the same registered attachment can be read by the real Markdown/code viewer')
|
||||
await assert.rejects(invoke('resources:read-text', pickedPath), /超出工作目录/)
|
||||
await assert.rejects(invoke('attachments:preview', '../outside'), /标识无效/)
|
||||
await assert.rejects(invoke('attachments:stage', {
|
||||
name: 'metadata.json', mimeType: 'text/plain', dataBase64: Buffer.from('no').toString('base64')
|
||||
}), /文件名无效/)
|
||||
|
||||
const tamperedMetadata = join(state.userData, 'attachments', staged.id, 'metadata.json')
|
||||
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, path: join(tempRoot, 'outside.txt') }), 'utf8')
|
||||
const pathChecked = await bundle.attachments.getAttachment(staged.id)
|
||||
assert.equal(pathChecked.path, staged.path, 'attachment metadata path must be recomputed inside the staged directory')
|
||||
assert.notEqual(pathChecked.path, join(tempRoot, 'outside.txt'))
|
||||
assert.equal((await invoke('attachments:preview', staged.id) as { path: string }).path, staged.path,
|
||||
'attachment preview must use the registered directory, not a metadata path override')
|
||||
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, name: '../outside.txt', path: join(tempRoot, 'outside.txt') }), 'utf8')
|
||||
await assert.rejects(bundle.attachments.getAttachment(staged.id), /元数据无效/)
|
||||
const sendSession = await invoke('session:create', 'invalid-attachment') as { id: string }
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: sendSession.id, text: 'must not send', attachments: [{ ...staged, id: staged.id }]
|
||||
}), /元数据无效/)
|
||||
assert.deepEqual(await invoke('session:messages', sendSession.id), [], 'invalid attachment metadata must reject before appending')
|
||||
|
||||
const sendWithAttachment = await invoke('session:create', 'attachment-send') as { id: string }
|
||||
await invoke('chat:send', { sessionId: sendWithAttachment.id, text: 'read attachment', attachments: [picked[0]] })
|
||||
await waitForAssistant(invoke, sendWithAttachment.id)
|
||||
const prompt = state.timeline.findLast((item) => item.kind === 'prompt' && item.sessionId === sendWithAttachment.id)
|
||||
assert.ok(prompt?.text?.includes('附件 picked.txt:\npicked file content'), 'OMP prompt should receive staged file contents')
|
||||
|
||||
await assert.rejects(invoke('attachments:remove', picked[0].id), /已用于会话/)
|
||||
assert.ok(await readFile(join(state.userData, 'attachments', picked[0].id, 'metadata.json'), 'utf8'),
|
||||
'removing a committed attachment should leave the session source intact')
|
||||
await invoke('attachments:remove', staged.id)
|
||||
await assert.rejects(bundle.attachments.getAttachment(staged.id))
|
||||
}
|
||||
|
||||
async function testSpeech(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
|
||||
request.on('end', () => {
|
||||
state.speechRequests.push({
|
||||
path: request.url || '',
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8')
|
||||
})
|
||||
response.writeHead(state.speechStatus, { 'content-type': 'application/json' })
|
||||
response.end(state.speechStatus === 200 ? JSON.stringify({ text: 'recognized local audio' }) : JSON.stringify({ error: 'mock failure' }))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
const speechBaseUrl = `http://127.0.0.1:${address.port}`
|
||||
const invoke = invokeFor(state)
|
||||
await invoke('config:update', { speechBaseUrl, speechModel: 'local-test-model', apiKey: 'regression-only-key' })
|
||||
const audioBase64 = Buffer.from('local fake wav bytes').toString('base64')
|
||||
const result = await invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }) as { text: string }
|
||||
assert.deepEqual(result, { text: 'recognized local audio' })
|
||||
const request = state.speechRequests.at(-1)
|
||||
assert.ok(request)
|
||||
assert.equal(request.path, '/audio/transcriptions')
|
||||
assert.equal(request.authorization, 'Bearer regression-only-key')
|
||||
assert.ok(request.contentType?.includes('multipart/form-data; boundary='))
|
||||
assert.ok(request.body.includes('name="model"') && request.body.includes('local-test-model'))
|
||||
assert.ok(request.body.includes('filename="recording.wav"') && request.body.includes('local fake wav bytes'))
|
||||
|
||||
state.speechStatus = 503
|
||||
await assert.rejects(invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }), /转写失败(503)/)
|
||||
assert.equal(state.speechRequests.length, 2, 'both transcription requests should stay on the local server')
|
||||
assert.ok(state.speechRequests.every((item) => item.path === '/audio/transcriptions'))
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
async function testPendingReplay(
|
||||
tempRoot: string,
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
markerSessionId: string,
|
||||
markerEntryId: string
|
||||
): Promise<TestBundle> {
|
||||
// A real OMP cold start loads the old file leaf; branch alone was not persisted.
|
||||
seedNativeHistory(state, markerSessionId, [[markerEntryId, 'write prompt']])
|
||||
const restarted = await buildBundle(tempRoot, state, 'conversation-ipc-restarted')
|
||||
const invoke = register(restarted, state)
|
||||
const before = await restarted.sessions.getMessages(markerSessionId)
|
||||
assert.deepEqual(before, [], 'restarted session should load the archived active prefix')
|
||||
|
||||
state.failBranchIds.add(markerEntryId)
|
||||
await assert.rejects(invoke('chat:send', { sessionId: markerSessionId, text: 'blocked until branch succeeds' }), /恢复已保存的原生会话分支/)
|
||||
state.failBranchIds.delete(markerEntryId)
|
||||
assert.deepEqual(await restarted.sessions.getMessages(markerSessionId), before,
|
||||
'failed pending branch replay must not append a user message')
|
||||
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), markerEntryId,
|
||||
'failed pending branch replay must retain its marker for a retry')
|
||||
|
||||
const start = state.timeline.length
|
||||
await invoke('chat:send', { sessionId: markerSessionId, text: 'replayed branch prompt' })
|
||||
const messages = await waitForAssistant(invoke, markerSessionId)
|
||||
const activity = state.timeline.slice(start).filter((item) => item.sessionId === markerSessionId)
|
||||
const branch = activity.findIndex((item) => item.kind === 'branch' && item.entryId === markerEntryId)
|
||||
const prompt = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === 'replayed branch prompt')
|
||||
assert.ok(branch >= 0 && prompt > branch, 'fresh OMP kernel must replay the saved branch marker before prompt')
|
||||
assert.equal(activity[prompt].text, ['replayed branch prompt', messages[0].capabilityContext].filter(Boolean).join('\n\n'),
|
||||
'restart prompt retains the exact current turn context')
|
||||
assert.match(activity[prompt].text ?? '', /<ycode-agent-mode mode="craft">/, 'restart prompt declares current craft mode')
|
||||
assert.equal(messages[0].content, 'replayed branch prompt', 'persisted user content stays exact across restart')
|
||||
assert.ok(messages[0].nativeEntryId?.startsWith('native-prompt-'), 'restarted send should map its returned native entryId')
|
||||
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), undefined,
|
||||
'new native user entry should clear the marker after successful lookup')
|
||||
return restarted
|
||||
}
|
||||
|
||||
async function testColdRecovery(tempRoot: string, state: TestState, bundle: TestBundle): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', 'cold streaming recovery') as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message('cold-user', 'user', 'incomplete request', { status: 'done' }))
|
||||
await bundle.sessions.appendMessage(session.id, message('cold-assistant', 'assistant', 'partial', {
|
||||
requestId: 'cold-run',
|
||||
status: 'streaming',
|
||||
parts: [{ type: 'text', text: 'partial' }, { type: 'tool', toolId: 'cold-tool' }],
|
||||
tools: [{ id: 'cold-tool', name: 'shell', args: {}, argsRaw: '{}', status: 'running', output: 'partial tool output' }]
|
||||
}))
|
||||
|
||||
const cold = await buildBundle(tempRoot, state, 'conversation-ipc-cold-recovery')
|
||||
const messages = await cold.sessions.getMessages(session.id)
|
||||
const recovered = assistant(messages)
|
||||
assert.equal(recovered.status, 'cancelled')
|
||||
assert.equal(recovered.interrupted, true)
|
||||
assert.equal(recovered.tools?.[0].status, 'cancelled')
|
||||
assert.equal(recovered.tools?.[0].output, 'partial tool output', 'cold recovery should retain running tool output')
|
||||
const disk = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
|
||||
sessions: Array<{ id: string; messages: ChatMessage[] }>
|
||||
}
|
||||
const diskRecovered = disk.sessions.find((item) => item.id === session.id)?.messages.at(-1)
|
||||
assert.equal(diskRecovered?.status, 'cancelled', 'cold recovery should persist its terminal status')
|
||||
assert.equal(diskRecovered?.tools?.[0].status, 'cancelled')
|
||||
assert.equal(diskRecovered?.tools?.[0].output, 'partial tool output')
|
||||
}
|
||||
|
||||
async function testInlineReferencePersistence(state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<string> {
|
||||
const session = await invoke('session:create', 'inline reference persistence') as { id: string }
|
||||
const quote = { id: 'inline-q', sourceId: 'original:table:table-0', kind: 'table', markdown: '| 项目 |\n| --- |\n| alpha |', preview: '| old raw markdown |' }
|
||||
const layout = [{ type: 'text', text: 'before' }, { type: 'quote', id: quote.id }, { type: 'text', text: 'after' }]
|
||||
await invoke('chat:send', { sessionId: session.id, text: 'beforeafter', quotes: [quote], userContent: [
|
||||
...layout, { type: 'attachment', id: 'untrusted-view-only' }, { type: 'quote', id: quote.id }
|
||||
] })
|
||||
const messages = await waitForAssistant(invoke, session.id)
|
||||
const user = messages.find(item => item.role === 'user')!
|
||||
assert.deepEqual(user.userContent, layout, 'main process must preserve mixed order, deduplicate references, and reject forged view identities')
|
||||
assert.equal(user.quotes?.[0].markdown, quote.markdown, 'the real table snapshot must survive independently of the UI label')
|
||||
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as { sessions: Array<{ id: string; messages: ChatMessage[] }> }
|
||||
assert.deepEqual(stored.sessions.find(item => item.id === session.id)?.messages.find(item => item.role === 'user')?.userContent, layout,
|
||||
'inline presentation must reach the actual atomic session file')
|
||||
return session.id
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-conversation-ipc-'))
|
||||
const state: TestState = {
|
||||
userData: join(tempRoot, 'userData'),
|
||||
home: join(tempRoot, 'home'),
|
||||
handlers: new Map(),
|
||||
dialogPaths: [],
|
||||
nativeMessages: new Map(),
|
||||
failBranchIds: new Set(),
|
||||
timeline: [],
|
||||
pendingPrompts: [],
|
||||
holdSessions: new Set(),
|
||||
activePrompts: 0,
|
||||
maxActivePrompts: 0,
|
||||
nextNativeId: 0,
|
||||
speechStatus: 200,
|
||||
speechRequests: [],
|
||||
events: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
await mkdir(state.home, { recursive: true })
|
||||
|
||||
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
||||
const previousState = globals[TEST_STATE_KEY]
|
||||
globals[TEST_STATE_KEY] = state
|
||||
const bundles: TestBundle[] = []
|
||||
try {
|
||||
const bundle = await buildBundle(tempRoot, state, 'conversation-ipc-primary')
|
||||
bundles.push(bundle)
|
||||
const invoke = register(bundle, state)
|
||||
|
||||
const workspace = join(tempRoot, 'workspace')
|
||||
await mkdir(workspace, { recursive: true })
|
||||
state.dialogPaths.push(workspace)
|
||||
assert.deepEqual(await invoke('workspace:choose'), { root: workspace, changed: true })
|
||||
|
||||
const project = await invoke('projects:create', { name: '指令项目', instructions: '项目回复必须包含验收标记 PROJECT-CONTEXT', experts: [], skills: [], connectors: [] }) as { projectId: string }
|
||||
const projectTask = await invoke('session:create', '项目指令回归', { projectId: project.projectId }) as { id: string; projectId: string }
|
||||
assert.equal(projectTask.projectId, project.projectId)
|
||||
await invoke('chat:send', { sessionId: projectTask.id, text: '检查项目指令' })
|
||||
const projectMessages = await waitForAssistant(invoke, projectTask.id)
|
||||
const projectPrompt = state.timeline.find(item => item.kind === 'prompt' && item.sessionId === projectTask.id)?.text
|
||||
assert.ok(projectPrompt?.includes('项目指令:\n项目回复必须包含验收标记 PROJECT-CONTEXT'), 'project instructions must reach the actual OMP adapter prompt')
|
||||
assert.equal(projectMessages.find(item => item.role === 'user')?.content, '检查项目指令', 'project instructions must not overwrite the displayed user message')
|
||||
await invoke('projects:update', project.projectId, { instructions: 'UPDATED-PROJECT-CONTEXT' })
|
||||
await invoke('chat:send', { sessionId: projectTask.id, text: '检查更新指令' })
|
||||
await waitForAssistant(invoke, projectTask.id)
|
||||
const updatedProjectPrompt = state.timeline.filter(item => item.kind === 'prompt' && item.sessionId === projectTask.id).at(-1)?.text
|
||||
assert.ok(updatedProjectPrompt?.includes('UPDATED-PROJECT-CONTEXT'), 'next project turn must use the updated persisted instructions')
|
||||
await assert.rejects(invoke('session:update-context', projectTask.id, { projectId: 'another-project' }), /目标项目/)
|
||||
await testConcurrency(bundle, state, invoke)
|
||||
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
|
||||
await testActionBranchFailure(bundle, state)
|
||||
await testEditArtifactRollback(bundle, state, workspace)
|
||||
await testSenderRoutingAndPreflight(bundle, state, workspace)
|
||||
await testInterruptedResend(bundle, state)
|
||||
await testAttachments(bundle, state, tempRoot)
|
||||
await testSpeech(bundle, state)
|
||||
const inlineSessionId = await testInlineReferencePersistence(state, invoke)
|
||||
|
||||
const { markerSessionId, markerEntryId } = await testFeedbackAndRevert(bundle, state, workspace)
|
||||
const restarted = await testPendingReplay(tempRoot, bundle, state, markerSessionId, markerEntryId)
|
||||
bundles.push(restarted)
|
||||
await testColdRecovery(tempRoot, state, restarted)
|
||||
const restoredInline = await invoke('session:messages', inlineSessionId) as ChatMessage[]
|
||||
assert.deepEqual(restoredInline.find(item => item.role === 'user')?.userContent,
|
||||
[{ type: 'text', text: 'before' }, { type: 'quote', id: 'inline-q' }, { type: 'text', text: 'after' }],
|
||||
'a fresh IPC/session repository must restore the same inline order after restart')
|
||||
console.log('conversation-ipc-regression: all checks passed')
|
||||
} finally {
|
||||
await Promise.all(bundles.map((bundle) => bundle.disposeOmpKernels().catch(() => undefined)))
|
||||
globals[TEST_STATE_KEY] = previousState
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error('conversation-ipc-regression failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
|
||||
import { executeConversationTool } from '../src/main/agent/conversation-tools'
|
||||
import { runOmpTurn } from '../src/main/agent/omp-turn'
|
||||
import { restoreWorkspaceFiles } from '../src/main/workspace-revert'
|
||||
import { getRevertChanges } from '../src/shared/revert'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import type { ChatEvent, ChatMessage } from '../src/shared/types'
|
||||
import type { ChatInteraction } from '../src/shared/conversation'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
import { assistantWireHistory } from '../src/main/agent/history'
|
||||
|
||||
async function main() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ycode-conversation-runtime-'))
|
||||
const workspace = join(directory, 'workspace')
|
||||
await mkdir(workspace)
|
||||
try {
|
||||
const events: ChatEvent[] = []
|
||||
const emitted = new EventEmitter()
|
||||
const emit = (event: ChatEvent) => { events.push(event); emitted.emit('event', event) }
|
||||
const controller = new AbortController()
|
||||
const broker = createInteractionBroker('run', controller.signal, emit)
|
||||
const context = { runId: 'run', toolCallId: 'write', workspaceRoot: workspace, signal: controller.signal, interactions: broker, emit }
|
||||
const nextInteraction = () => new Promise<ChatInteraction>((resolve) => {
|
||||
const listener = (event: ChatEvent) => { if (event.type === 'interaction' && event.interaction.status === 'pending') { emitted.off('event', listener); resolve(event.interaction) } }
|
||||
emitted.on('event', listener)
|
||||
})
|
||||
const write = executeConversationTool('write_file', { path: 'nested/new/file.txt', content: 'first' }, context)
|
||||
const written = await write
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'first')
|
||||
assert.equal((written.data as { oldContent: unknown }).oldContent, null)
|
||||
assert.equal(events.some(event => event.type === 'interaction'), false, 'host file writes no longer create sandbox approvals')
|
||||
|
||||
const edit = executeConversationTool('edit_file', { path: 'nested/new/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
assert.equal((await edit).ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'second')
|
||||
const outside = join(directory, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'secret.txt'), 'private')
|
||||
assert.equal((await executeConversationTool('write_file', { path: '../outside/deep/new.txt', content: 'x' }, context)).ok, false)
|
||||
const hostCommand = await executeConversationTool('execute_command', { command: 'echo HOST_COMMAND_OK' }, context)
|
||||
assert.equal(hostCommand.ok, true)
|
||||
assert.match(hostCommand.content, /HOST_COMMAND_OK/)
|
||||
const fullController = new AbortController()
|
||||
const childScript = join(directory, 'child.cjs')
|
||||
const parentScript = join(directory, 'parent.cjs')
|
||||
const pidFile = join(directory, 'child-pid.txt')
|
||||
await writeFile(childScript, `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`)
|
||||
await writeFile(parentScript, `require('node:child_process').spawn(process.execPath,[${JSON.stringify(childScript)}],{windowsHide:true,stdio:'ignore'});process.stdout.write('child started');setInterval(()=>{},1000)`)
|
||||
const fullCommand = executeConversationTool('execute_command', { command: `"${process.execPath}" "${parentScript}"` }, { ...context, signal: fullController.signal })
|
||||
let childPid = 0
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
childPid = Number(await readFile(pidFile, 'utf8').catch(() => '0'))
|
||||
if (childPid) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
fullController.abort()
|
||||
const terminated = await fullCommand
|
||||
assert.ok(childPid, 'command should have spawned its own child for cancellation verification')
|
||||
assert.equal(terminated.ok, false)
|
||||
assert.throws(() => process.kill(childPid, 0), 'cancellation must terminate the shell child tree')
|
||||
|
||||
const questionPending = nextInteraction()
|
||||
const questionTask = executeConversationTool('ask_user_question', { questions: [
|
||||
{ id: 'q1', question: 'Question one?', options: ['A', 'B'] },
|
||||
{ id: 'q2', question: 'Question two?', options: ['C', 'D'], multiSelect: true }
|
||||
] }, { ...context, toolCallId: 'question' })
|
||||
const question = await questionPending
|
||||
respondToInteraction('run', question.id, { kind: 'question', answer: { questions: [
|
||||
{ id: 'q1', question: 'untrusted changed label', answers: [] },
|
||||
{ id: 'q2', question: 'Question two?', answers: ['C', 'custom answer'] }
|
||||
] } }, emit)
|
||||
const answered = await questionTask
|
||||
assert.equal(JSON.parse(answered.content).questions[0].question, 'Question one?')
|
||||
assert.deepEqual((answered.metadata!.questionAnswer as { questions: unknown[] }).questions.length, 2)
|
||||
const aborting = new AbortController()
|
||||
const abortBroker = createInteractionBroker('abort', aborting.signal, emit)
|
||||
const cancelled = abortBroker.question('cancel-question', 'Cancel?', [{ id: 'q', question: 'Cancel?', options: ['yes'] }])
|
||||
const abortedAssertion = assert.rejects(cancelled, { name: 'AbortError' })
|
||||
aborting.abort()
|
||||
await abortedAssertion
|
||||
assert.ok(events.some((event) => event.type === 'interaction' && event.interaction.status === 'cancelled'))
|
||||
|
||||
let message: ChatMessage = { id: 'assistant', role: 'assistant', content: '', createdAt: 0, status: 'streaming' }
|
||||
const planEmit = (event: ChatEvent) => { message = applyAssistantEvent(message, event) }
|
||||
const firstPlan = await executeConversationTool('todo_write', { todos: [{ id: '1', content: 'step', status: 'in_progress' }] }, { ...context, emit: planEmit })
|
||||
const lastPlan = await executeConversationTool('todo_write', { todos: [{ id: '1', content: 'step', status: 'completed' }] }, { ...context, emit: planEmit })
|
||||
assert.equal(message.parts, undefined, 'TodoWrite produces a specialized tool result rather than a duplicate plan part')
|
||||
for (const result of [firstPlan, lastPlan]) planEmit({ runId: 'run', type: 'part', part: { type: 'plan', plan: result.data as import('../src/shared/conversation').PlanContent } })
|
||||
assert.equal(message.parts?.length, 1, 'plan updates replace the same plan without duplicated cards')
|
||||
assert.equal(message.parts?.[0].type === 'plan' && message.parts[0].plan.steps[0].status, 'completed')
|
||||
message = applyAssistantEvent(message, { runId: 'run', type: 'done', content: 'complete', tools: [{ id: 'final-only', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }] })
|
||||
assert.deepEqual(message.parts?.map((part) => part.type), ['text', 'plan', 'tool'])
|
||||
const exchanges = assistantWireHistory({ id: 'history', role: 'assistant', content: 'beforeafter', createdAt: 0,
|
||||
parts: [{ type: 'text', text: 'before' }, { type: 'tool', toolId: 'read' }, { type: 'text', text: 'after' }],
|
||||
tools: [{ id: 'read', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok', output: 'complete tool output' }] })
|
||||
assert.deepEqual(exchanges.map((entry) => entry.role), ['assistant', 'tool', 'assistant'])
|
||||
assert.equal(exchanges[0].content, 'before')
|
||||
assert.equal(exchanges[1].content, 'complete tool output')
|
||||
assert.equal(exchanges[2].content, 'after')
|
||||
|
||||
await writeFile(join(workspace, 'a.txt'), 'new A')
|
||||
await writeFile(join(workspace, 'b.txt'), 'new B')
|
||||
const history: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 }, { id: 'assistant', role: 'assistant', content: 'done', createdAt: 1, status: 'done', tools: [
|
||||
{ id: 'a', name: 'edit_file', args: {}, argsRaw: '{}', status: 'ok', result: { path: join(workspace, 'a.txt'), oldContent: 'old A', newContent: 'new A' } },
|
||||
{ id: 'b', name: 'write_file', args: {}, argsRaw: '{}', status: 'ok', result: { path: join(workspace, 'b.txt'), oldContent: null, newContent: 'new B' } }
|
||||
] }]
|
||||
const changes = getRevertChanges(history, 'user')
|
||||
await writeFile(join(workspace, 'b.txt'), 'manual B')
|
||||
await assert.rejects(restoreWorkspaceFiles(workspace, changes), /外部修改/)
|
||||
assert.equal(await readFile(join(workspace, 'a.txt'), 'utf8'), 'new A', 'all conflicts are checked before the first write')
|
||||
await writeFile(join(workspace, 'b.txt'), 'new B')
|
||||
const undo = await restoreWorkspaceFiles(workspace, changes)
|
||||
assert.equal(await readFile(join(workspace, 'a.txt'), 'utf8'), 'old A')
|
||||
await assert.rejects(readFile(join(workspace, 'b.txt')), { code: 'ENOENT' })
|
||||
await undo()
|
||||
assert.equal(await readFile(join(workspace, 'b.txt'), 'utf8'), 'new B')
|
||||
|
||||
class HostKernel extends EventEmitter {
|
||||
definitions: unknown[] = []
|
||||
async setHostTools(tools: unknown[]) { this.definitions = tools; return { success: true } }
|
||||
async prompt() {
|
||||
queueMicrotask(() => {
|
||||
this.emit('event', { type: 'tool_execution_start', toolCallId: 'native-read', toolName: 'read_file', args: { path: 'a.txt' } })
|
||||
this.emit('event', { type: 'host_tool_call', id: 'host-request', toolCallId: 'native-read', toolName: 'read_file', arguments: { path: 'a.txt' } })
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
async respondToHostTool(id: string, content: string, isError: boolean) {
|
||||
assert.equal(id, 'host-request')
|
||||
assert.equal(isError, false)
|
||||
assert.match(content, /new A/)
|
||||
this.emit('event', { type: 'tool_execution_end', toolCallId: 'native-read', result: { content: [{ type: 'text', text: content }] } })
|
||||
this.emit('event', { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Native host complete' } })
|
||||
this.emit('event', { type: 'agent_end' })
|
||||
}
|
||||
async abort() { return { success: true } }
|
||||
}
|
||||
const hostKernel = new HostKernel()
|
||||
const native = await runOmpTurn({ runId: 'native', kernel: hostKernel as unknown as OmpKernel, workspaceRoot: workspace, userText: 'read', signal: controller.signal, emit })
|
||||
assert.equal(native.content, 'Native host complete')
|
||||
assert.equal(hostKernel.definitions.length, 9)
|
||||
assert.ok(hostKernel.definitions.some(tool => tool.name === 'browser'), 'native host registers the browser tool')
|
||||
assert.equal(native.tools[0].status, 'ok')
|
||||
assert.equal((native.tools[0].result as { path: string }).path, join(workspace, 'a.txt'))
|
||||
assert.equal(hostKernel.listenerCount('event'), 0)
|
||||
console.log('conversation-runtime-regression: approval, questions, cancellation, real file writes/revert, path constraints, plan updates and native host adapter passed')
|
||||
} finally { await rm(directory, { recursive: true, force: true }) }
|
||||
}
|
||||
void main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { Gateway, type GatewayDelta, type WireMessage } from '../src/main/gateway'
|
||||
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
|
||||
import { discoverModelConnection } from '../src/main/model-connection'
|
||||
import type { GatewayConfig } from '../src/shared/types'
|
||||
|
||||
const checks: string[] = []
|
||||
let mode = 'tools', protocol: 'anthropic' | 'gemini' = 'anthropic', noKey = false
|
||||
const observed: Array<{ path: string; body: any }> = []
|
||||
const sse = (rows: unknown[]): string => rows.map(row => `data: ${JSON.stringify(row)}\n\n`).join('')
|
||||
function anthropic(): unknown[] {
|
||||
const rows: any[] = [
|
||||
{ type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: 4, cache_creation_input_tokens: 2 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '考虑步骤' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'controlled-signature' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'native-call', name: 'inspect', input: {} } },
|
||||
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":' } },
|
||||
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"src"}' } },
|
||||
{ type: 'content_block_stop', index: 1 }
|
||||
]
|
||||
if (mode !== 'truncated') rows.push({ type: 'message_delta', delta: { stop_reason: mode === 'limit' ? 'max_tokens' : 'tool_use' }, usage: { output_tokens: 8 } }, { type: 'message_stop' })
|
||||
if (mode === 'error') rows.push({ type: 'error', error: { message: 'private-key-that-must-not-leak' } })
|
||||
return rows
|
||||
}
|
||||
function gemini(): unknown[] {
|
||||
const rows: any[] = [{ candidates: [{ index: 0, content: { role: 'model', parts: [{ thought: true, text: '考虑步骤' }] } }] },
|
||||
{ candidates: [{ index: 0, content: { role: 'model', parts: [{ functionCall: { id: 'native-call', name: 'inspect', args: { path: 'src' } }, thoughtSignature: 'controlled-signature' }] },
|
||||
...(mode !== 'truncated' ? { finishReason: mode === 'limit' ? 'MAX_TOKENS' : 'STOP' } : {}) }],
|
||||
usageMetadata: { promptTokenCount: 16, candidatesTokenCount: 5, thoughtsTokenCount: 3, totalTokenCount: 24, cachedContentTokenCount: 4 } }]
|
||||
if (mode === 'error') rows.push({ error: { message: 'private-key-that-must-not-leak' } })
|
||||
return rows
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://local')
|
||||
if (url.pathname.startsWith('/root-test')) {
|
||||
assert.equal(url.pathname, '/root-test/chat/completions')
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
const body = JSON.parse(bytes)
|
||||
assert.equal(body.max_completion_tokens, 1024); assert.equal(body.max_tokens, undefined)
|
||||
assert.equal(body.reasoning_effort, 'none'); assert.equal(body.temperature, undefined)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse([{ choices: [{ delta: { content: 'OK' }, finish_reason: 'stop' }] }]) + 'data: [DONE]\n\n'); return
|
||||
}
|
||||
const authenticated = noKey ? !req.headers.authorization && !req.headers['x-api-key'] && !req.headers['x-goog-api-key']
|
||||
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-key' : req.headers['x-goog-api-key'] === 'controlled-key'
|
||||
if (!authenticated) { res.writeHead(401); res.end('{}'); return }
|
||||
if (req.method === 'GET') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(protocol === 'anthropic'
|
||||
? url.searchParams.has('after_id') ? { data: [{ id: 'second' }, { id: 'first' }], has_more: false } : { data: [{ id: 'first' }], has_more: true, last_id: 'first' }
|
||||
: url.searchParams.has('pageToken') ? { models: [{ name: 'models/second', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embedding', supportedGenerationMethods: ['embedContent'] }] }
|
||||
: { models: [{ name: 'models/first', supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'next' })); return
|
||||
}
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
observed.push({ path: req.url!, body: JSON.parse(bytes) })
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse(protocol === 'anthropic' ? anthropic() : gemini()))
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert(address && typeof address === 'object')
|
||||
const root = `http://127.0.0.1:${address.port}`
|
||||
const collect = async (config: GatewayConfig): Promise<GatewayDelta[]> => { const events: GatewayDelta[] = []; for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'inspect src' }] })) events.push(event); return events }
|
||||
try {
|
||||
const geminiAuto: GatewayConfig = { api: 'google-generative-ai', baseUrl: root, model: 'gemini-3.1-pro-preview', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
|
||||
supportsReasoning: true, thinkingLevel: 'auto', reasoningOptions: [{ type: 'effort', values: ['low', 'medium', 'high'] }] }
|
||||
assert.deepEqual(buildGeminiRequest(geminiAuto, { messages: [] }).generationConfig.thinkingConfig, { includeThoughts: true })
|
||||
assert.deepEqual(buildGeminiRequest({ ...geminiAuto, thinkingLevel: 'low' }, { messages: [] }).generationConfig.thinkingConfig, { thinkingLevel: 'LOW', includeThoughts: true })
|
||||
checks.push('Gemini effort-only models use native thinking levels without inventing a token budget for auto')
|
||||
const rootConfig: GatewayConfig = { api: 'openai-completions', baseUrl: root + '/root-test', model: 'gpt-5.4', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
|
||||
maxOutputTokens: 1024, supportsReasoning: true, thinkingLevel: 'off', reasoningOptions: [{ type: 'effort', values: ['none', 'low', 'high'] }], temperatureSupported: false }
|
||||
assert((await collect(rootConfig)).some(event => event.type === 'text' && event.text === 'OK'))
|
||||
checks.push('OpenAI fallback honors the negotiated root, modern completion budget and disabled reasoning/temperature')
|
||||
for (protocol of ['anthropic', 'gemini'] as const) {
|
||||
noKey = false; mode = 'tools'
|
||||
const config: GatewayConfig = { api: protocol === 'anthropic' ? 'anthropic-messages' : 'google-generative-ai', baseUrl: root + (protocol === 'anthropic' ? '/v1' : '/v1beta'), model: 'controlled-model', apiKey: 'controlled-key', temperature: 0, maxToolRounds: 3 }
|
||||
const discovery = await discoverModelConnection(root, protocol, 'controlled-key', true)
|
||||
assert.deepEqual(discovery.models.map(model => model.id), ['first', 'second']); checks.push(protocol + ' lists every page, filters non-generative IDs and removes duplicates')
|
||||
const events = await collect(config), native = events.find(event => event.type === 'native_content')!, calls = events.find(event => event.type === 'tool_calls')!
|
||||
assert(native.type === 'native_content' && calls.type === 'tool_calls')
|
||||
assert.deepEqual(calls.calls[0], { id: 'native-call', type: 'function', function: { name: 'inspect', arguments: '{"path":"src"}' } })
|
||||
assert.equal(events.filter(event => event.type === 'reasoning').map(event => event.text).join(''), '考虑步骤')
|
||||
const usage = events.filter(event => event.type === 'usage').at(-1)!
|
||||
assert(usage.type === 'usage'); assert.deepEqual(usage.usage, { inputTokens: 16, outputTokens: 8, totalTokens: 24, cachedTokens: 4 })
|
||||
const messages: WireMessage[] = [{ role: 'user', content: 'inspect src' }, { role: 'assistant', content: null, tool_calls: calls.calls, providerContent: { api: native.api, content: native.content } }, { role: 'tool', tool_call_id: 'native-call', content: 'contents' }]
|
||||
const body = protocol === 'anthropic' ? buildAnthropicRequest(config, { messages }) : buildGeminiRequest(config, { messages })
|
||||
assert(JSON.stringify(body).includes('controlled-signature'))
|
||||
assert(JSON.stringify(body).includes('native-call')); assert(JSON.stringify(body).includes('contents'))
|
||||
checks.push(protocol + ' native tool IDs, arguments, reasoning, signatures and usage survive tool continuation')
|
||||
for (mode of ['truncated', 'limit', 'error']) {
|
||||
const pending: GatewayDelta[] = []
|
||||
await assert.rejects(async () => { for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'go' }] })) pending.push(event) }, error => error instanceof Error && !error.message.includes('private-key-that-must-not-leak'))
|
||||
assert(!pending.some(event => event.type === 'tool_calls')); checks.push(protocol + ' ' + mode + ' never dispatches incomplete tools or leaks provider error content')
|
||||
}
|
||||
mode = 'tools'; noKey = true
|
||||
assert.equal((await discoverModelConnection(root, protocol, '', true)).models.length, 2)
|
||||
assert((await collect({ ...config, authMode: 'none', apiKey: '' })).some(event => event.type === 'tool_calls'))
|
||||
checks.push(protocol + ' keyless custom endpoints send no authentication headers')
|
||||
}
|
||||
console.log('CUSTOM_MODEL_NATIVE_PASS ' + JSON.stringify({ checks }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -1,130 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { once } from 'node:events'
|
||||
import { normalizeModelAddress, endpointCandidates, endpointUrl, modelEndpoint } from '../src/shared/model-endpoint'
|
||||
import { cheapestModelPrice, lookupModelMetadata, type ModelCatalog } from '../src/shared/model-metadata'
|
||||
import { discoverModelConnection, modelFetch } from '../src/main/model-connection'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
|
||||
import { prepareOmpModel, supportsOmpModel } from '../src/main/omp-model'
|
||||
import { OmpKernel, detectOmpPath } from '../src/main/omp'
|
||||
import type { ModelApi, ResolvedModel } from '../src/shared/models'
|
||||
|
||||
const directory = await mkdtemp(resolve('.tmp/custom-model-regression-'))
|
||||
const checks: string[] = []
|
||||
function check(label: string, run: () => void): void { run(); checks.push(label) }
|
||||
const catalog = JSON.parse(await readFile('resources/model-metadata/models.dev.json', 'utf8')) as ModelCatalog
|
||||
const source = JSON.parse(await readFile('resources/model-metadata/manifest.json', 'utf8'))
|
||||
check('domain-only, local origin and full routes normalize', () => {
|
||||
assert.equal(normalizeModelAddress('api.example.com'), 'https://api.example.com')
|
||||
assert.equal(normalizeModelAddress('127.0.0.1:9000'), 'http://127.0.0.1:9000')
|
||||
assert.throws(() => normalizeModelAddress('https://user:password@example.com'))
|
||||
assert.throws(() => normalizeModelAddress('https://example.com?key=secret'))
|
||||
assert(endpointCandidates('https://example.com/proxy', 'anthropic').some(endpoint => endpointUrl(endpoint, 'generate') === 'https://example.com/proxy/v1/messages'))
|
||||
})
|
||||
check('metadata uses the canonical provider and cheapest context tier', () => {
|
||||
const value = lookupModelMetadata(catalog, 'gpt-5.4', 'openai', source)!
|
||||
assert.equal(value.providerId, 'openai'); assert.equal(value.contextWindowTokens, 272000)
|
||||
assert.equal(value.maxInputTokens, 144000); assert(value.supportsImages); assert(value.supportsReasoning)
|
||||
assert.equal(value.price?.input, 2.5); assert.equal(value.price?.output, 15)
|
||||
assert.equal(lookupModelMetadata(catalog, 'openai/gpt-5.4', 'auto', source)?.modelId, 'gpt-5.4')
|
||||
assert.equal(lookupModelMetadata(catalog, 'gpt-5.4-not-a-real-alias', 'auto', source), null)
|
||||
assert.equal(cheapestModelPrice({ id: 'bad', cost: { input: -1, output: 1 } }), undefined)
|
||||
})
|
||||
check('tier thresholds constrain defaults without changing provider', () => {
|
||||
const price = cheapestModelPrice({ id: 'test', limit: { context: 100000 }, cost: { input: 4, output: 8, tiers: [{ input: 1, output: 2, tier: { type: 'context', size: 32000 } }, { input: 5, output: 10, tier: { type: 'context', size: 64000 } }] } })!
|
||||
assert.equal(price.minContext, 32000); assert.equal(price.maxContext, 64000)
|
||||
})
|
||||
|
||||
let api: ModelApi = 'anthropic-messages', anonymous = false
|
||||
const requests: Array<{ api: ModelApi; path: string; body: any }> = []
|
||||
const rejectedAuth: unknown[] = []
|
||||
const anthropic = (model: string): string => [
|
||||
{ type: 'message_start', message: { id: 'msg_test', type: 'message', role: 'assistant', content: [], model, stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '协议正常' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } },
|
||||
{ type: 'message_stop' }
|
||||
].map(value => `event: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`).join('')
|
||||
const gemini = (): string => 'data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: '协议正常' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 } }) + '\n\n'
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://local')
|
||||
if (url.pathname === '/redirect') { res.writeHead(302, { location: 'http://127.0.0.1:1/trap' }); res.end(); return }
|
||||
if (req.method === 'GET') {
|
||||
const valid = api === 'anthropic-messages' ? url.pathname === '/v1/models' && req.headers['x-api-key'] === 'controlled-test-key'
|
||||
: api === 'google-generative-ai' ? url.pathname === '/v1beta/models' && req.headers['x-goog-api-key'] === 'controlled-test-key' : url.pathname === '/v1/models' && req.headers.authorization === 'Bearer controlled-test-key'
|
||||
res.writeHead(valid ? 200 : 404, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(api === 'google-generative-ai' ? { models: [{ name: 'models/gemini-2.5-flash', displayName: 'Gemini test', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embed', supportedGenerationMethods: ['embedContent'] }] }
|
||||
: { data: [{ id: 'claude-3-5-haiku-latest' }, { id: 'claude-3-5-haiku-latest' }] })); return
|
||||
}
|
||||
let raw = ''; for await (const chunk of req) raw += chunk
|
||||
const authorized = anonymous ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
|
||||
: api === 'anthropic-messages' ? req.headers['x-api-key'] === 'controlled-test-key' && !req.headers.authorization : req.headers['x-goog-api-key'] === 'controlled-test-key'
|
||||
if (!authorized) { rejectedAuth.push({ api, anonymous, path: req.url, headers: Object.keys(req.headers).filter(name => /api-key|authorization/.test(name)), googleSentinel: req.headers['x-goog-api-key'] === 'N/A' }); res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Incorrect native authentication' } })); return }
|
||||
const body = JSON.parse(raw || '{}'); requests.push({ api, path: req.url!, body })
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end(api === 'anthropic-messages' ? anthropic(body.model) : gemini())
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert(address && typeof address === 'object')
|
||||
const root = `http://127.0.0.1:${address.port}`
|
||||
try {
|
||||
await assert.rejects(() => modelFetch(root + '/redirect', {}), /不同域名/); checks.push('credentials cannot follow a cross-origin redirect')
|
||||
for (const currentApi of ['anthropic-messages', 'google-generative-ai'] as const) {
|
||||
api = currentApi
|
||||
const discovery = await discoverModelConnection(root, 'auto', 'controlled-test-key', true)
|
||||
assert.equal(discovery.endpoint.api, api); assert.equal(discovery.models.length, 1)
|
||||
checks.push(api + ' model discovery negotiates path and auth, filters and deduplicates')
|
||||
const config = { api, baseUrl: discovery.endpoint.baseUrl, model: api === 'anthropic-messages' ? 'claude-3-5-haiku-latest' : 'gemini-2.5-flash', apiKey: 'controlled-test-key', temperature: 0, maxToolRounds: 2, maxOutputTokens: 128 }
|
||||
const events = []
|
||||
for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'hello' }] })) events.push(event)
|
||||
assert.equal(events.filter(event => event.type === 'text').map(event => event.text).join(''), '协议正常')
|
||||
assert(events.some(event => event.type === 'usage' && event.usage.totalTokens === 12))
|
||||
checks.push(api + ' fallback gateway streams native text and usage')
|
||||
const body = api === 'anthropic-messages' ? buildAnthropicRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
|
||||
: buildGeminiRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
|
||||
assert(JSON.stringify(body).includes('AAAA')); checks.push(api + ' image input preserves base64 data')
|
||||
|
||||
const executable = detectOmpPath(); assert(executable, 'Real OMP required for native protocol acceptance')
|
||||
for (const authMode of ['api-key', 'none'] as const) {
|
||||
anonymous = authMode === 'none'
|
||||
const resolved: ResolvedModel = { profile: { id: config.model, providerId: 'custom', name: config.model, model: config.model, baseUrl: root, protocol: api === 'anthropic-messages' ? 'anthropic' : 'gemini', authMode, hasApiKey: !anonymous, keyStorage: 'none', supportsReasoning: true, supportsImages: true }, config: { ...config, authMode, apiKey: anonymous ? '' : config.apiKey } }
|
||||
if (!supportsOmpModel(resolved)) {
|
||||
assert.equal(api, 'google-generative-ai'); assert.equal(authMode, 'none')
|
||||
await assert.rejects(() => prepareOmpModel(resolved), /原生 HTTP/)
|
||||
checks.push('keyless Gemini explicitly routes to the native HTTP gateway rather than sending OMP sentinel credentials')
|
||||
continue
|
||||
}
|
||||
process.env.YCODE_OMP_HOME = directory
|
||||
const managed = await prepareOmpModel(resolved), frames: string[] = [], errors: string[] = []
|
||||
assert(!(await readFile(resolve(directory, '.omp/profiles', managed.profile, 'agent/models.yml'), 'utf8')).includes(config.apiKey), 'native profile must never store the API key')
|
||||
const kernel = new OmpKernel(executable, directory, managed.profile, undefined, managed.model, managed.env)
|
||||
kernel.on('error', error => errors.push(String(error)))
|
||||
kernel.on('stderr', value => { if (/error/i.test(String(value))) errors.push(String(value).slice(0, 300)) })
|
||||
const start = requests.length
|
||||
try {
|
||||
await kernel.start()
|
||||
const auto = await kernel.send({ type: 'set_thinking_level', level: 'auto' })
|
||||
assert.equal(auto.success, true, 'OMP must accept automatic thinking intensity')
|
||||
await kernel.setThinkingLevel('minimal')
|
||||
const end = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(Error('Native protocol turn timeout: ' + JSON.stringify({ api, paths: requests.slice(start).map(r => r.path), frames, errors }))), 18000)
|
||||
kernel.on('event', frame => { frames.push(frame.type); if (frame.type === 'agent_end') { clearTimeout(timer); resolve() } })
|
||||
})
|
||||
await kernel.prompt('Reply briefly.'); await end
|
||||
assert(requests.length > start, JSON.stringify({ api, authMode, frames, errors, rejectedAuth }))
|
||||
const request = requests[start]
|
||||
console.log(JSON.stringify({ api, nativePath: request.path, thinking: request.body.thinking ?? request.body.generationConfig?.thinkingConfig, expectedRoot: discovery.endpoint.baseUrl, frameTypes: [...new Set(frames)], errors }))
|
||||
const nativeUrl = new URL(request.path, root)
|
||||
assert.equal(nativeUrl.pathname, api === 'anthropic-messages' ? '/v1/messages' : '/v1beta/models/gemini-2.5-flash:streamGenerateContent')
|
||||
if (api === 'google-generative-ai') assert.equal(nativeUrl.searchParams.get('alt'), 'sse')
|
||||
checks.push(api + ' real OMP native adapter completes using the negotiated route and ' + authMode + ' authentication')
|
||||
} finally { await kernel.dispose() }
|
||||
}
|
||||
anonymous = false
|
||||
}
|
||||
console.log('CUSTOM_MODEL_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -1,115 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { useChatStore } from '../src/renderer/src/stores/chat'
|
||||
import { clearExpertInputPrefill, expertInputUpdate } from '../src/renderer/src/capabilities/expert-input'
|
||||
|
||||
const events: Array<{ owner: string; prompt: string; explicit: boolean }> = []
|
||||
const writes: Array<{ owner: string; expertId: string | null }> = []
|
||||
const calls: string[] = []
|
||||
const gates = new Map<string, ReturnType<typeof deferred<any>>>()
|
||||
let failWrite = false
|
||||
function deferred<T>() { let resolve!: (value: T) => void, reject!: (error: Error) => void; const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
function activation(id: string) { return { id, name: id, expertType: 'agent' as const, runtimeIdentity: 'expert-v2:' + id, defaultInitPrompt: { zh: '默认提问:' + id } } }
|
||||
const sessions = [
|
||||
{ id: 'one', title: 'one', createdAt: 1, updatedAt: 1, workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'old-expert' },
|
||||
{ id: 'two', title: 'two', createdAt: 2, updatedAt: 2 }
|
||||
]
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: class { type: string; detail: unknown; constructor(type: string, options: { detail: unknown }) { this.type = type; this.detail = options.detail } } })
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
dispatchEvent(event: { type: string; detail: any }) { if (event.type === 'ycode:expert-input') events.push(event.detail); return true },
|
||||
ycode: { experts: { activate: async (id: string) => { calls.push(id); return gates.get(id)?.promise ?? activation(id) } },
|
||||
session: { messages: async () => [], updateContext: async (owner: string, patch: any) => { if (failWrite) throw Error('disk unavailable'); writes.push({ owner, expertId: patch.expertId }); return { ...sessions.find(session => session.id === owner), ...patch } } } }
|
||||
} })
|
||||
function reset(owner: string | null = null) {
|
||||
useChatStore.getState().startTask({ cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model' })
|
||||
useChatStore.setState({ sessions: structuredClone(sessions), currentSessionId: owner, messages: [], sessionMessages: {}, running: null, sending: false, runs: {}, sendingSessions: [] })
|
||||
events.length = 0; writes.length = 0; calls.length = 0; gates.clear(); failWrite = false
|
||||
}
|
||||
let checks = 0
|
||||
function eq(actual: unknown, expected: unknown, label: string) { assert.deepEqual(actual, expected, label); checks++ }
|
||||
|
||||
const empty = { text: '', attachments: [] as unknown[] }
|
||||
eq(expertInputUpdate(empty, 'A').text, 'A', 'empty draft gets default')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user question' }, 'A').text, 'user question', 'user text survives')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, 'B').text, 'B', 'unchanged expert default replaced')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }, 'B').text, 'A edited', 'edited default survives')
|
||||
eq(expertInputUpdate({ ...empty, attachments: ['image'] }, 'A').text, '', 'non-text-only attachment survives without autofill')
|
||||
eq(expertInputUpdate({ ...empty, resources: ['file'] }, 'A').text, '', 'non-text-only reference survives without autofill')
|
||||
eq(expertInputUpdate({ ...empty, skills: ['skill'] }, 'A').text, '', 'manual skill-only input survives')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A', attachments: ['image'] }, 'B').attachments, ['image'], 'replacing default keeps attachments')
|
||||
eq(expertInputUpdate({ ...empty, text: 'edit', editingId: 'old-message' }, 'A').text, 'edit', 'editing message preserved')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user', attachments: ['image'] }, 'explicit', true).text, 'explicit', 'explicit quick prompt adopted')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '').text, 'A', 'expert without default keeps input')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '', false, true).text, '', 'conversation expert without default clears old prefill')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user edited', autoFilledPrompt: 'A' }, '', false, true).text, 'user edited', 'conversation expert without default preserves user text')
|
||||
eq(clearExpertInputPrefill({ ...empty, text: 'A', autoFilledPrompt: 'A' }).text, '', 'conversation clear removes untouched prefill')
|
||||
eq(clearExpertInputPrefill({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }).text, 'A edited', 'conversation clear preserves edited text')
|
||||
|
||||
reset()
|
||||
const draft = useChatStore.getState().draftId
|
||||
await useChatStore.getState().selectExpert('cpp')
|
||||
eq(useChatStore.getState().draftId, draft, 'picker binds same unsent owner')
|
||||
eq(useChatStore.getState().draftContext, { cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model', expertId: 'cpp', agentMode: 'craft' }, 'model and workspace preserved and expert resumes craft')
|
||||
eq(events, [{ owner: `new-${draft}`, prompt: '默认提问:cpp', explicit: false }], 'single validated activation supplies default')
|
||||
eq(writes.length, 0, 'no persisted empty conversation created')
|
||||
await useChatStore.getState().selectExpert('cpp')
|
||||
eq(calls.length, 2, 'explicit repeat refreshes activation like source provider')
|
||||
await useChatStore.getState().selectExpert(null)
|
||||
eq(useChatStore.getState().draftContext.expertId, null, 'remove pending expert')
|
||||
eq(events.length, 2, 'homepage remove never rewrites input')
|
||||
|
||||
reset('one')
|
||||
await useChatStore.getState().summonExpert('pm')
|
||||
eq(writes, [{ owner: 'one', expertId: 'pm' }], 'market return binds existing conversation')
|
||||
eq(useChatStore.getState().currentSessionId, 'one', 'return retains conversation')
|
||||
eq(events.length, 1, 'market return offers default with user input protection in composer')
|
||||
eq(useChatStore.getState().sessions[0].modelId, 'custom-model', 'conversation model unchanged')
|
||||
await useChatStore.getState().selectExpert('ui')
|
||||
eq(events.at(-1)?.owner, 'one', 'picker targets same conversation')
|
||||
eq(events.at(-1)?.prompt, '默认提问:ui', 'picker default gated by current input in composer')
|
||||
|
||||
reset('one')
|
||||
await useChatStore.getState().summonExpert('algo', { newTask: true })
|
||||
eq(useChatStore.getState().currentSessionId, null, 'standalone market summon prepares homepage')
|
||||
eq(useChatStore.getState().draftContext, { workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'algo', agentMode: 'craft' }, 'new expert task inherits model and space and uses craft')
|
||||
eq(writes.length, 0, 'standalone summon leaves old session unchanged')
|
||||
eq(events.at(-1)?.owner, `new-${useChatStore.getState().draftId}`, 'default follows new owner immediately')
|
||||
|
||||
reset('one'); failWrite = true
|
||||
await assert.rejects(useChatStore.getState().selectExpert('bad'), /disk unavailable/); checks++
|
||||
eq(useChatStore.getState().sessions[0].expertId, 'old-expert', 'failed persist retains prior expert')
|
||||
eq(events.length, 0, 'failed persist leaves draft untouched')
|
||||
|
||||
reset(); const bad = deferred<any>(); gates.set('bad', bad)
|
||||
const rejected = useChatStore.getState().selectExpert('bad'); bad.reject(Error('missing dependency'))
|
||||
await assert.rejects(rejected, /missing dependency/); checks++
|
||||
eq(useChatStore.getState().draftContext.expertId, undefined, 'failed activation does not bind')
|
||||
eq(events.length, 0, 'failed activation never inserts default')
|
||||
|
||||
reset(); const slow = deferred<any>(); gates.set('slow', slow)
|
||||
const stale = useChatStore.getState().selectExpert('slow')
|
||||
await useChatStore.getState().selectExpert('latest')
|
||||
slow.resolve(activation('slow'))
|
||||
await assert.rejects(stale, /选择已切换/); checks++
|
||||
eq(useChatStore.getState().draftContext.expertId, 'latest', 'late activation cannot overwrite latest')
|
||||
eq(events.map(event => event.prompt), ['默认提问:latest'], 'late activation cannot overwrite text')
|
||||
|
||||
reset(); const shared = deferred<any>(); gates.set('same', shared)
|
||||
const first = useChatStore.getState().selectExpert('same'), second = useChatStore.getState().selectExpert('same')
|
||||
const obsolete = assert.rejects(first, /选择已切换/)
|
||||
shared.resolve(activation('same')); await Promise.all([obsolete, second]); checks++
|
||||
eq(calls, ['same'], 'concurrent same expert shares activation')
|
||||
eq(events.length, 1, 'only newest concurrent selection publishes')
|
||||
|
||||
reset('one'); const late = deferred<any>(); gates.set('late', late)
|
||||
const departed = useChatStore.getState().selectExpert('late')
|
||||
await useChatStore.getState().selectSession('two'); late.resolve(activation('late'))
|
||||
await assert.rejects(departed, /任务或专家选择已切换/); checks++
|
||||
eq(writes.length, 0, 'changing conversation prevents stale bind')
|
||||
eq(events.length, 0, 'changing conversation prevents stale prompt')
|
||||
|
||||
reset('one'); useChatStore.setState({ running: { runId: 'running', assistantId: 'assistant', sessionId: 'one' } })
|
||||
await assert.rejects(useChatStore.getState().selectExpert('next'), /先停止/); checks++
|
||||
eq(calls.length, 0, 'running conversation switch rejected before activation')
|
||||
await useChatStore.getState().summonExpert('next', { newTask: true })
|
||||
eq(useChatStore.getState().draftContext.expertId, 'next', 'independent new task may be prepared during another turn')
|
||||
console.log(JSON.stringify({ result: 'PASS', checks, scope: 'production Zustand expert state and composer input policy with controlled IPC; no model requests' }))
|
||||
@@ -1,4 +1,4 @@
|
||||
// Deterministic UI data. Native/OMP execution is verified separately by the runtime regressions.
|
||||
// Deterministic UI data. Native kernel execution is verified separately by the runtime regressions.
|
||||
const now = Date.now()
|
||||
const message = (id, role, content, extra = {}) => ({ id, role, content, createdAt: now, status: 'done', ...extra })
|
||||
const tool = (id, name, args, result) => ({ id, name, args, argsRaw: JSON.stringify(args), status: 'ok', result, output: typeof result === 'string' ? result : JSON.stringify(result) })
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer, type ServerResponse } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { resolve, join, relative, isAbsolute } from 'node:path'
|
||||
import { Gateway, GatewayError, type GatewayDelta, type WireMessage } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { responsesInput } from '../src/main/gateway/responses'
|
||||
import type { GatewayConfig, ChatEvent } from '../src/shared/types'
|
||||
|
||||
const directory = await mkdtemp(resolve('.tmp/gateway-responses-'))
|
||||
const rel = relative(resolve('.tmp'), directory)
|
||||
assert.ok(!isAbsolute(rel) && !rel.startsWith('..') && rel.startsWith('gateway-responses-'))
|
||||
await writeFile(join(directory, 'proof.txt'), 'only controlled local data')
|
||||
const secret = 'controlled-secret-must-not-appear-in-errors'
|
||||
const requests: Array<{ path: string; body: any; authorizationPresent: boolean }> = []
|
||||
const checks: string[] = []
|
||||
const check = (name: string, actual: unknown, expected: unknown): void => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
const payload = (type: string, values: Record<string, unknown> = {}): Record<string, unknown> => ({ type, ...values })
|
||||
const frame = (data: unknown, event?: string): string => `${event ? 'event: ' + event + '\r\n' : ''}data: ${JSON.stringify(data)}\r\n\r\n`
|
||||
const complete = (output: unknown[] = []): Record<string, unknown> => payload('response.completed', { response: { id: 'response-controlled', status: 'completed', output } })
|
||||
const functionItem = { type: 'function_call', id: 'fc_internal', call_id: 'call_public', name: 'list_dir', arguments: '{"path":"."}', status: 'completed' }
|
||||
const messageItem = (text: string, id = 'msg_1'): Record<string, unknown> => ({ type: 'message', id, role: 'assistant', status: 'completed', content: [{ type: 'output_text', text, annotations: [] }] })
|
||||
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: '先看目录。' }] }
|
||||
async function send(res: ServerResponse, events: unknown[], tail = ''): Promise<void> {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const data = Buffer.from(': keepalive\r\n\r\ndata: invalid-json-heartbeat\r\n\r\n' + events.map(item => frame(item)).join('') + tail)
|
||||
// Actual HTTP chunks split UTF-8 glyphs, CRLF boundaries and JSON tokens.
|
||||
for (let start = 0; start < data.length; start += 13) {
|
||||
if (res.destroyed) return
|
||||
if (!res.write(data.subarray(start, start + 13))) await once(res, 'drain')
|
||||
if (start % 143 === 0) await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
let raw = ''; for await (const chunk of req) raw += chunk
|
||||
const body = JSON.parse(raw), url = new URL(req.url ?? '', 'http://127.0.0.1'), mode = url.searchParams.get('case')
|
||||
requests.push({ path: req.url ?? '', body, authorizationPresent: !!req.headers.authorization })
|
||||
if (url.pathname.endsWith('/chat/completions')) {
|
||||
await send(res, [{ choices: [{ delta: { content: '旧 Chat 保留' }, finish_reason: 'stop' }] }], 'data: [DONE]\n\n'); return
|
||||
}
|
||||
assert.equal(url.pathname, '/v1/responses')
|
||||
if (mode === 'http-error') { res.writeHead(429, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: secret } })); return }
|
||||
if (mode === 'stream-error') { await send(res, [payload('error', { message: secret, code: 'provider_failure' })]); return }
|
||||
if (mode === 'failed') { await send(res, [payload('response.failed', { response: { status: 'failed', error: { message: secret } } })]); return }
|
||||
if (mode === 'incomplete') { await send(res, [payload('response.output_item.added', { output_index: 0, item: { ...functionItem, status: 'in_progress' } }), payload('response.incomplete', { response: { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } } })]); return }
|
||||
if (mode === 'wrong-status') { await send(res, [payload('response.completed', { response: { status: 'failed', error: { message: secret } } })]); return }
|
||||
if (mode === 'missing-status' || mode === 'missing-output') {
|
||||
await send(res, [payload('response.output_item.done', { output_index: 0, item: functionItem }),
|
||||
payload('response.completed', { response: mode === 'missing-status' ? { output: [functionItem] } : { status: 'completed' } })]); return
|
||||
}
|
||||
if (mode === 'truncated') { await send(res, [payload('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '部分文本' })], 'data: [DONE]\n\n'); return }
|
||||
if (mode === 'cancel') {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write(frame(payload('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '取消前文本' })))
|
||||
return
|
||||
}
|
||||
if (mode === 'tool') {
|
||||
if (body.input.some((item: any) => item.type === 'function_call_output')) {
|
||||
await send(res, [payload('response.output_text.delta', { item_id: 'msg_final', output_index: 0, content_index: 0, delta: '完成读取。' }), complete([messageItem('完成读取。', 'msg_final')])]); return
|
||||
}
|
||||
await send(res, [
|
||||
payload('response.created', { response: { status: 'in_progress' } }),
|
||||
payload('response.output_item.added', { output_index: 0, item: { type: 'reasoning', id: 'rs_1', summary: [] } }),
|
||||
payload('response.reasoning_summary_text.delta', { item_id: 'rs_1', output_index: 0, summary_index: 0, delta: '先看' }),
|
||||
payload('response.reasoning_summary_text.delta', { item_id: 'rs_1', output_index: 0, summary_index: 0, delta: '目录。' }),
|
||||
payload('response.reasoning_summary_text.done', { item_id: 'rs_1', output_index: 0, summary_index: 0, text: '先看目录。' }),
|
||||
payload('response.output_item.done', { output_index: 0, item: reasoningItem }),
|
||||
payload('response.output_text.delta', { item_id: 'msg_1', output_index: 1, content_index: 0, delta: '查看' }),
|
||||
payload('response.output_text.done', { item_id: 'msg_1', output_index: 1, content_index: 0, text: '查看目录。' }),
|
||||
payload('response.output_item.added', { output_index: 2, item: { ...functionItem, arguments: '', status: 'in_progress' } }),
|
||||
payload('response.function_call_arguments.delta', { item_id: 'fc_internal', output_index: 2, delta: '{"pa' }),
|
||||
payload('response.function_call_arguments.delta', { item_id: 'fc_internal', output_index: 2, delta: 'th":"."}' }),
|
||||
payload('response.function_call_arguments.done', { item_id: 'fc_internal', output_index: 2, arguments: '{"path":"."}' }),
|
||||
payload('response.output_item.done', { output_index: 2, item: functionItem }),
|
||||
complete([reasoningItem, messageItem('查看目录。'), functionItem])]); return
|
||||
}
|
||||
if (mode === 'snapshot') {
|
||||
await send(res, [complete([reasoningItem, messageItem('仅完成快照。')])]); return
|
||||
}
|
||||
if (mode === 'parallel') {
|
||||
await send(res, [payload('response.function_call_arguments.delta', { output_index: 1, item_id: 'fc_second', delta: '{"b":' }),
|
||||
payload('response.output_item.added', { output_index: 0, item: { type: 'function_call', id: 'fc_first', call_id: 'call_first', name: 'one', arguments: '', status: 'in_progress' } }),
|
||||
payload('response.function_call_arguments.done', { output_index: 0, item_id: 'fc_first', arguments: '{"a":1}' }),
|
||||
payload('response.output_item.done', { output_index: 0, item: { type: 'function_call', id: 'fc_first', call_id: 'call_first', name: 'one', arguments: '{"a":1}' } }),
|
||||
payload('response.output_item.done', { output_index: 1, item: { type: 'function_call', id: 'fc_second', call_id: 'call_second', name: 'two', arguments: '{"b":2}' } }), complete()]); return
|
||||
}
|
||||
if (mode === 'invalid-call') { await send(res, [complete([{ ...functionItem, arguments: '{' }])]); return }
|
||||
if (mode === 'missing-id') { await send(res, [complete([{ ...functionItem, call_id: '' }])]); return }
|
||||
await send(res, [payload('response.output_text.delta', { output_index: 0, content_index: 0, delta: '你好' }),
|
||||
payload('response.output_text.done', { item_id: 'msg_1', output_index: 0, content_index: 0, text: '你好,Responses。' }),
|
||||
payload('response.content_part.done', { item_id: 'msg_1', output_index: 0, content_index: 0, part: { type: 'output_text', text: '你好,Responses。' } }),
|
||||
complete([messageItem('你好,Responses。')])])
|
||||
})().catch(() => { res.destroy() })
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert.ok(address && typeof address === 'object')
|
||||
const base = `http://127.0.0.1:${address.port}`
|
||||
const config = (mode: string, suffix = '/v1/responses'): GatewayConfig => ({ baseUrl: `${base}${suffix}?case=${mode}`, model: 'gpt-6-luna', apiKey: secret, temperature: 0.3, maxToolRounds: 4, maxOutputTokens: 1024 })
|
||||
const request = { messages: [{ role: 'user' as const, content: '受控测试' }] }
|
||||
async function collect(gateway: Gateway, input = request): Promise<GatewayDelta[]> { const result: GatewayDelta[] = []; for await (const delta of gateway.stream(input)) result.push(delta); return result }
|
||||
const joined = (deltas: GatewayDelta[], type: 'text' | 'reasoning'): string => deltas.filter(delta => delta.type === type).map(delta => 'text' in delta ? delta.text : '').join('')
|
||||
try {
|
||||
const events: ChatEvent[] = []
|
||||
const result = await runTurn({ runId: 'controlled-responses', history: [], userText: '列出本地受控文件', signal: new AbortController().signal,
|
||||
emit: event => events.push(event), gateway: new Gateway(config('tool')), model: 'gpt-6-luna', maxToolRounds: 4, workspaceRoot: directory })
|
||||
check('real fallback loop executes function and completes next response', [result.content, result.tools.length, result.tools[0].status], ['查看目录。完成读取。', 1, 'ok'])
|
||||
const rounds = requests.filter(req => req.path.includes('case=tool'))
|
||||
check('complete endpoint has exactly two rounds and no repeated protocol path', rounds.map(req => req.path), ['/v1/responses?case=tool', '/v1/responses?case=tool'])
|
||||
check('function tools flatten and keep optional parameter semantics', [rounds[0].body.tools[0].type, rounds[0].body.tools[0].name, rounds[0].body.tools[0].strict, rounds[0].body.tools[0].function], ['function', 'list_dir', false, undefined])
|
||||
check('Responses token/temperature/model fields', [rounds[0].body.max_output_tokens, rounds[0].body.max_tokens, rounds[0].body.temperature, rounds[0].body.model, rounds[0].body.store], [1024, undefined, 0.3, 'gpt-6-luna', false])
|
||||
const call = rounds[1].body.input.find((item: any) => item.type === 'function_call'), output = rounds[1].body.input.find((item: any) => item.type === 'function_call_output')
|
||||
check('history replays public call_id, exact JSON and matching output', [call.call_id, call.arguments, output.call_id, output.output.includes('proof.txt')], ['call_public', '{"path":"."}', 'call_public', true])
|
||||
check('reasoning summary deltas do not duplicate snapshots', events.filter(event => event.type === 'part' && event.part.type === 'reasoning').at(-1)?.type === 'part' ? (events.filter(event => event.type === 'part' && event.part.type === 'reasoning').at(-1) as any).part.text : '', '先看目录。')
|
||||
check('fallback emits done exactly once and no error', [events.filter(event => event.type === 'done').length, events.filter(event => event.type === 'error').length], [1, 0])
|
||||
const multimodal = await collect(new Gateway(config('text')), { messages: [{ role: 'user', content: [{ type: 'text', text: '看图' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,Y29udHJvbGxlZA==' } }] }] })
|
||||
const multimodalRequest = requests.at(-1)!
|
||||
check('image/text convert to Responses input content', multimodalRequest.body.input[0].content, [{ type: 'input_text', text: '看图' }, { type: 'input_image', image_url: 'data:image/png;base64,Y29udHJvbGxlZA==', detail: 'auto' }])
|
||||
check('no tools means no tools/tool_choice keys', [multimodalRequest.body.tools, multimodalRequest.body.tool_choice], [undefined, undefined])
|
||||
check('text delta/done/part/item/completed snapshots deduplicate even late item id', [joined(multimodal, 'text'), multimodal.at(-1)], ['你好,Responses。', { type: 'finish', reason: 'stop' }])
|
||||
const snapshots = await collect(new Gateway(config('snapshot')))
|
||||
check('snapshot-only response can deliver text/reasoning', [joined(snapshots, 'text'), joined(snapshots, 'reasoning')], ['仅完成快照。', '先看目录。'])
|
||||
const parallel = await collect(new Gateway(config('parallel'))), calls = parallel.find(delta => delta.type === 'tool_calls')
|
||||
check('parallel calls reconcile by item/index and order by output_index', calls?.type === 'tool_calls' ? calls.calls.map(call => [call.id, call.function.name, call.function.arguments]) : [], [['call_first', 'one', '{"a":1}'], ['call_second', 'two', '{"b":2}']])
|
||||
check('tool finish is emitted once', parallel.filter(delta => delta.type === 'finish'), [{ type: 'finish', reason: 'tool_calls' }])
|
||||
for (const mode of ['http-error', 'stream-error', 'failed', 'wrong-status', 'missing-status', 'missing-output', 'incomplete', 'truncated', 'invalid-call', 'missing-id']) {
|
||||
const deltas: GatewayDelta[] = []
|
||||
let error: unknown
|
||||
try { for await (const delta of new Gateway(config(mode)).stream(request)) deltas.push(delta) } catch (reason) { error = reason }
|
||||
check(`${mode}: throws GatewayError without successful finish or executable calls`, [error instanceof GatewayError, deltas.some(delta => delta.type === 'finish' || delta.type === 'tool_calls'), String(error).includes(secret), (error as GatewayError).body], [true, false, false, undefined])
|
||||
if (mode === 'http-error') check('HTTP failure retains safe status', (error as GatewayError).status, 429)
|
||||
}
|
||||
const failedEvents: ChatEvent[] = []
|
||||
await assert.rejects(runTurn({ runId: 'incomplete-turn', history: [], userText: '受控未完成', signal: new AbortController().signal,
|
||||
emit: event => failedEvents.push(event), gateway: new Gateway(config('incomplete')), model: 'gpt-6-luna', maxToolRounds: 2, workspaceRoot: directory }), GatewayError)
|
||||
check('incomplete fallback loop emits error rather than done/tool execution', [failedEvents.some(event => event.type === 'error'), failedEvents.some(event => event.type === 'done' || event.type === 'tool_call')], [true, false])
|
||||
const abort = new AbortController(), cancelled: GatewayDelta[] = []
|
||||
await assert.rejects(async () => { for await (const delta of new Gateway(config('cancel')).stream({ ...request, signal: abort.signal })) { cancelled.push(delta); abort.abort() } }, { name: 'AbortError' })
|
||||
check('abort preserves partial text and never finishes', [joined(cancelled, 'text'), cancelled.some(delta => delta.type === 'finish')], ['取消前文本', false])
|
||||
for (const suffix of ['/v1', '/v1/chat/completions/']) {
|
||||
const chat = await collect(new Gateway(config('chat', suffix)))
|
||||
check('legacy Chat endpoint and stream preserved: ' + suffix, [requests.at(-1)!.path, joined(chat, 'text'), requests.at(-1)!.body.max_tokens, requests.at(-1)!.body.max_output_tokens, requests.at(-1)!.body.messages], ['/v1/chat/completions?case=chat', '旧 Chat 保留', 1024, undefined, request.messages])
|
||||
}
|
||||
const history: WireMessage[] = [{ role: 'system', content: 'system' }, { role: 'assistant', content: null, tool_calls: [{ id: 'public', type: 'function', function: { name: 'f', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'public', content: 'ok' }, { role: 'assistant', content: 'reply' }]
|
||||
check('system/assistant/tool history has no null assistant tool-only message', responsesInput(history), [{ role: 'system', content: 'system' }, { type: 'function_call', call_id: 'public', name: 'f', arguments: '{}' }, { type: 'function_call_output', call_id: 'public', output: 'ok' }, { role: 'assistant', content: 'reply' }])
|
||||
assert.throws(() => responsesInput([{ role: 'tool', content: 'missing' }]), /调用 ID/); checks.push('missing historical tool call id fails locally')
|
||||
const report = { result: 'PASS', scope: 'Controlled loopback HTTP + actual Gateway/fallback runTurn/local list_dir; no real user endpoint or OMP', checks, requestCount: requests.length }
|
||||
await writeFile(join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify({ ...report, directory }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -68,7 +68,7 @@ async function main(): Promise<void> {
|
||||
await writeFixture(reportPath, '# Updated report\n')
|
||||
await writeFixture(tablePath, 'name,value\na,1\n')
|
||||
await writeFixture(resultPath, 'written result')
|
||||
await writeFixture(nativeWritePath, 'native OMP write')
|
||||
await writeFixture(nativeWritePath, 'native tool write')
|
||||
|
||||
const messagesA: ChatMessage[] = [
|
||||
message('user-presentation', 500, {
|
||||
@@ -188,7 +188,7 @@ async function main(): Promise<void> {
|
||||
const nativeWriteMessages = [message('native-write-message', 3_500, {
|
||||
tools: [tool('native-write', 'write', {
|
||||
path: 'native/native-write.md',
|
||||
content: 'native OMP write'
|
||||
content: 'native tool write'
|
||||
})]
|
||||
})]
|
||||
const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, {
|
||||
@@ -196,7 +196,7 @@ async function main(): Promise<void> {
|
||||
cwd: root,
|
||||
sourceSessionTitle: '原生写入任务'
|
||||
})
|
||||
assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取')
|
||||
assert.equal(nativeCandidates.length, 1, '小写 native tool write 应经 Write 别名规范化后提取')
|
||||
assert.equal(nativeCandidates[0].sourceKind, 'write')
|
||||
assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write')
|
||||
assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath))
|
||||
|
||||
@@ -77,5 +77,5 @@ const full = resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, con
|
||||
assert.equal(full.maxInputTokens, 922000); assert.deepEqual(full.compaction, { reserveTokens: 157500, thresholdTokens: 892500 })
|
||||
const small = resolveModelTokenLimits({ tokenBudgetVersion: 2, contextWindowTokens: 8192, maxOutputTokens: 1234 })
|
||||
assert.deepEqual(small.compaction, { reserveTokens: 1234, thresholdTokens: 6958 })
|
||||
checks.push('compression follows the larger of output reserve and OMP headroom, including small windows')
|
||||
checks.push('compression follows the larger of output reserve and runtime headroom, including small windows')
|
||||
console.log('MODEL_METADATA_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
|
||||
@@ -1,11 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalize } from 'node:path'
|
||||
import { detectOmpPath } from '../src/main/omp'
|
||||
|
||||
delete process.env['YCODE_OMP_PATH']
|
||||
const detected = detectOmpPath()
|
||||
assert.ok(detected, 'bundled OMP must be detected without YCODE_OMP_PATH')
|
||||
const expected = normalize('resources/omp/omp.exe')
|
||||
assert.ok(normalize(detected).toLowerCase().endsWith(expected.toLowerCase()),
|
||||
`detected OMP must resolve to the bundled resources/omp/omp.exe, got ${detected}`)
|
||||
console.log(`PASS detectOmpPath => ${detected}`)
|
||||
@@ -1,710 +0,0 @@
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { createServer, type IncomingMessage } from 'node:http'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, parse, relative, resolve, sep } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
interface Frame {
|
||||
type?: string
|
||||
id?: string
|
||||
command?: string
|
||||
success?: boolean
|
||||
data?: unknown
|
||||
error?: { message?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProbeRun {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
frames: Frame[]
|
||||
rawLines: string[]
|
||||
stderrLines: string[]
|
||||
}
|
||||
|
||||
interface MockRequest {
|
||||
model?: string
|
||||
stream: boolean
|
||||
roles: string[]
|
||||
toolNames: string[]
|
||||
toolProperties: Record<string, string[]>
|
||||
containsImage: boolean
|
||||
messageShape: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const STARTUP_WAIT_MS = 12_000
|
||||
const COMMAND_WAIT_MS = 20_000
|
||||
const SHUTDOWN_WAIT_MS = 1_500
|
||||
const PROVIDER_ID = 'loopback-probe'
|
||||
const MODEL_ID = 'probe-model'
|
||||
const MODEL = `${PROVIDER_ID}/${MODEL_ID}`
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function makeIsolatedEnvironment(home: string, tempRoot: string, profile: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const name of ['PATH', 'SystemRoot', 'SystemDrive', 'ComSpec', 'WINDIR']) {
|
||||
const value = process.env[name]
|
||||
if (value) env[name] = value
|
||||
}
|
||||
|
||||
const root = parse(home).root
|
||||
env.USERPROFILE = home
|
||||
env.HOME = home
|
||||
env.HOMEDRIVE = root.replace(/[\\/]$/, '')
|
||||
env.HOMEPATH = relative(root, home).replaceAll('/', '\\')
|
||||
env.APPDATA = join(home, 'AppData', 'Roaming')
|
||||
env.LOCALAPPDATA = join(home, 'AppData', 'Local')
|
||||
env.TEMP = tempRoot
|
||||
env.TMP = tempRoot
|
||||
env.OMP_PROFILE = profile
|
||||
return env
|
||||
}
|
||||
|
||||
function summarizeFrame(frame: Frame): Record<string, unknown> {
|
||||
const data = frame.data
|
||||
const dataKeys = data && typeof data === 'object' ? Object.keys(data as object) : []
|
||||
const event = frame.assistantMessageEvent
|
||||
const eventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
return {
|
||||
type: frame.type,
|
||||
frameKeys: Object.keys(frame),
|
||||
id: frame.id,
|
||||
command: frame.command,
|
||||
success: frame.success,
|
||||
status: frame.status,
|
||||
assistantEventType: event && typeof event === 'object' ? (event as Record<string, unknown>).type : undefined,
|
||||
toolCallId: frame.toolCallId,
|
||||
toolName: frame.toolName,
|
||||
argumentKeys: frame.arguments && typeof frame.arguments === 'object' ? Object.keys(frame.arguments as object) : [],
|
||||
eventKeys,
|
||||
dataKeys,
|
||||
error: typeof frame.error === 'string' ? frame.error : frame.error?.message
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
run: ProbeRun,
|
||||
predicate: (frame: Frame) => boolean,
|
||||
timeoutMs = COMMAND_WAIT_MS,
|
||||
startIndex = 0
|
||||
): Promise<Frame | null> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const frame = run.frames.slice(startIndex).find(predicate)
|
||||
if (frame) return frame
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) break
|
||||
await delay(20)
|
||||
}
|
||||
return run.frames.slice(startIndex).find(predicate) ?? null
|
||||
}
|
||||
|
||||
async function sendCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
||||
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
||||
return waitForFrame(run, (frame) => frame.id === id)
|
||||
}
|
||||
|
||||
async function sendLooseCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
||||
const startIndex = run.frames.length
|
||||
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
||||
return waitForFrame(run, (frame) => frame.id === id || frame.type === 'response', 3_000, startIndex)
|
||||
}
|
||||
|
||||
async function installProbeHostTool(run: ProbeRun, suffix: string): Promise<Frame | null> {
|
||||
return sendCommand(run, `probe-host-tools-${suffix}`, {
|
||||
type: 'set_host_tools',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
label: 'Read local probe file',
|
||||
description: 'Read a file from the isolated protocol probe directory.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async function stopRun(run: ProbeRun): Promise<void> {
|
||||
try {
|
||||
run.child.stdin.end()
|
||||
} catch {
|
||||
// The sidecar may already have exited.
|
||||
}
|
||||
|
||||
await new Promise<void>((resolveExit) => {
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) {
|
||||
resolveExit()
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
}
|
||||
const timer = setTimeout(finish, SHUTDOWN_WAIT_MS)
|
||||
run.child.once('exit', finish)
|
||||
})
|
||||
|
||||
if (run.child.exitCode === null && run.child.signalCode === null) {
|
||||
run.child.kill()
|
||||
await new Promise<void>((resolveExit) => {
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) resolveExit()
|
||||
else run.child.once('exit', () => resolveExit())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function startRun(
|
||||
exePath: string,
|
||||
tempRoot: string,
|
||||
profile: string,
|
||||
home: string,
|
||||
sessionDir: string,
|
||||
sessionPath: string
|
||||
): Promise<ProbeRun> {
|
||||
const args = [
|
||||
'--mode', 'rpc',
|
||||
'--profile', profile,
|
||||
'--cwd', tempRoot,
|
||||
`--model=${MODEL}`,
|
||||
'--no-tools',
|
||||
`--session-dir=${sessionDir}`,
|
||||
`--resume=${sessionPath}`,
|
||||
'--no-ui',
|
||||
'--no-title',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-rules',
|
||||
'--no-lsp'
|
||||
]
|
||||
const child = spawn(exePath, args, {
|
||||
cwd: tempRoot,
|
||||
env: makeIsolatedEnvironment(home, tempRoot, profile),
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const run: ProbeRun = { child, frames: [], rawLines: [], stderrLines: [] }
|
||||
const stdout = createInterface({ input: child.stdout })
|
||||
const stderr = createInterface({ input: child.stderr })
|
||||
stdout.on('line', (line) => {
|
||||
run.rawLines.push(line)
|
||||
try {
|
||||
run.frames.push(JSON.parse(line) as Frame)
|
||||
} catch {
|
||||
// Keep non-JSON stdout separate from RPC frames.
|
||||
}
|
||||
})
|
||||
stderr.on('line', (line) => run.stderrLines.push(line))
|
||||
const ready = await waitForFrame(run, (frame) => frame.type === 'ready', STARTUP_WAIT_MS)
|
||||
if (!ready) {
|
||||
const stderrText = run.stderrLines.join('\n').slice(0, 1000)
|
||||
await stopRun(run)
|
||||
throw new Error(`OMP did not send ready; exit=${child.exitCode}; stderr=${stderrText}`)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
async function readRequest(request: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk))
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function toolNamesFrom(body: Record<string, unknown>): string[] {
|
||||
if (!Array.isArray(body.tools)) return []
|
||||
return body.tools.flatMap((tool) => {
|
||||
if (!tool || typeof tool !== 'object') return []
|
||||
const item = tool as Record<string, unknown>
|
||||
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
||||
const name = fn?.name ?? item.name
|
||||
return typeof name === 'string' ? [name] : []
|
||||
})
|
||||
}
|
||||
|
||||
function requestHasImage(body: Record<string, unknown>): boolean {
|
||||
if (!Array.isArray(body.messages)) return false
|
||||
return body.messages.some((message) => {
|
||||
if (!message || typeof message !== 'object') return false
|
||||
const content = (message as Record<string, unknown>).content
|
||||
return Array.isArray(content) && content.some((part) => {
|
||||
if (!part || typeof part !== 'object') return false
|
||||
const item = part as Record<string, unknown>
|
||||
return item.type === 'image_url' || item.type === 'image'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startMockProvider(probeFile: string): Promise<{
|
||||
server: ReturnType<typeof createServer>
|
||||
url: string
|
||||
requests: MockRequest[]
|
||||
toolCallSent: boolean
|
||||
}> {
|
||||
const requests: MockRequest[] = []
|
||||
let toolCallSent = false
|
||||
const server = createServer(async (request, response) => {
|
||||
const body = await readRequest(request)
|
||||
const names = toolNamesFrom(body)
|
||||
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
|
||||
const latestUser = [...messages].reverse().find((message) => message.role === 'user')
|
||||
const latestUserContent = Array.isArray(latestUser?.content)
|
||||
? (latestUser.content as Array<Record<string, unknown>>).map((block) => block.text).filter((value) => typeof value === 'string').join('\n')
|
||||
: typeof latestUser?.content === 'string' ? latestUser.content : ''
|
||||
const toolProperties: Record<string, string[]> = {}
|
||||
for (const tool of Array.isArray(body.tools) ? body.tools : []) {
|
||||
if (!tool || typeof tool !== 'object') continue
|
||||
const item = tool as Record<string, unknown>
|
||||
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
||||
const name = fn?.name ?? item.name
|
||||
const parameters = fn?.parameters && typeof fn.parameters === 'object' ? fn.parameters as Record<string, unknown> : null
|
||||
const properties = parameters?.properties && typeof parameters.properties === 'object'
|
||||
? parameters.properties as Record<string, unknown> : null
|
||||
if (typeof name === 'string') toolProperties[name] = Object.keys(properties ?? {})
|
||||
}
|
||||
requests.push({
|
||||
model: typeof body.model === 'string' ? body.model : undefined,
|
||||
stream: body.stream === true,
|
||||
roles: messages.map((message) => typeof message.role === 'string' ? message.role : '?'),
|
||||
toolNames: names,
|
||||
toolProperties,
|
||||
containsImage: requestHasImage(body),
|
||||
messageShape: messages.map((message) => {
|
||||
const content = message.content
|
||||
const blocks = Array.isArray(content) ? content as Array<Record<string, unknown>> : []
|
||||
const calls = Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
||||
return {
|
||||
role: message.role,
|
||||
contentType: typeof content === 'string' ? 'string' : Array.isArray(content) ? 'array' : typeof content,
|
||||
contentBlockTypes: blocks.map((block) => block.type),
|
||||
toolCallCount: calls.length,
|
||||
hasToolCallId: typeof message.tool_call_id === 'string'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const readToolName = names.find((name) => name === 'read' || name.endsWith('__read'))
|
||||
const sendReadTool = latestUserContent.includes('TOOL_PROBE') && readToolName && !toolCallSent
|
||||
const completion = sendReadTool
|
||||
? {
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: 'call_local_read_1',
|
||||
type: 'function',
|
||||
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
||||
}]
|
||||
},
|
||||
finish_reason: 'tool_calls'
|
||||
}
|
||||
: {
|
||||
message: { role: 'assistant', content: 'PROBE_OK' },
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
if (sendReadTool) toolCallSent = true
|
||||
if (body.stream === true) {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const completionId = `chatcmpl-local-${requests.length}`
|
||||
const writeChunk = (delta: Record<string, unknown>, finishReason: string | null = null): void => {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: completionId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
writeChunk({ role: 'assistant' })
|
||||
if (sendReadTool) {
|
||||
writeChunk({ tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_local_read_1',
|
||||
type: 'function',
|
||||
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
||||
}] })
|
||||
writeChunk({}, 'tool_calls')
|
||||
} else {
|
||||
writeChunk({ reasoning_content: 'LOCAL_THINKING_PROBE' })
|
||||
writeChunk({ content: 'PROBE_OK' })
|
||||
writeChunk({}, 'stop')
|
||||
}
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: completionId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
||||
})}\n\n`)
|
||||
response.end('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: `chatcmpl-local-${requests.length}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [{ index: 0, ...completion }],
|
||||
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
||||
}))
|
||||
})
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolveListen()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Loopback mock did not bind to a TCP port')
|
||||
return { server, url: `http://127.0.0.1:${address.port}/v1`, requests, get toolCallSent() { return toolCallSent } }
|
||||
}
|
||||
|
||||
function summarizeMessages(data: unknown): Record<string, unknown> {
|
||||
const root = data && typeof data === 'object' ? data as Record<string, unknown> : {}
|
||||
const possibleArrays = [root.messages, root.entries, root.branchMessages]
|
||||
const records = possibleArrays.find(Array.isArray) as unknown[] | undefined
|
||||
const items = (records ?? (Array.isArray(data) ? data : [])).map((value) => {
|
||||
if (!value || typeof value !== 'object') return { valueType: typeof value }
|
||||
const item = value as Record<string, unknown>
|
||||
const message = item.message && typeof item.message === 'object' ? item.message as Record<string, unknown> : item
|
||||
const content = Array.isArray(message.content) ? message.content as Array<Record<string, unknown>> : []
|
||||
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls as Array<Record<string, unknown>>
|
||||
: Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
||||
return {
|
||||
itemKeys: Object.keys(item),
|
||||
messageKeys: Object.keys(message),
|
||||
type: item.type,
|
||||
role: message.role,
|
||||
entryId: item.entryId ?? item.id,
|
||||
parentId: item.parentId,
|
||||
textBlocks: content.filter((block) => block.type === 'text').length,
|
||||
toolCalls: toolCalls.map((call) => call.name ?? (call.function as Record<string, unknown> | undefined)?.name)
|
||||
}
|
||||
})
|
||||
return { keys: Object.keys(root), count: items.length, messages: items }
|
||||
}
|
||||
|
||||
async function inspectTranscript(path: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const text = await readFile(path, 'utf8')
|
||||
const records = text.split(/\r?\n/).filter(Boolean).map((record) => JSON.parse(record) as Record<string, unknown>)
|
||||
return {
|
||||
exists: true,
|
||||
records: records.length,
|
||||
entries: records.map((record) => ({
|
||||
type: record.type,
|
||||
id: record.id,
|
||||
parentId: record.parentId,
|
||||
messageRole: (record.message as Record<string, unknown> | undefined)?.role,
|
||||
toolName: (record.toolName ?? (record.message as Record<string, unknown> | undefined)?.toolName) ?? undefined
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
return { exists: false }
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTree(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(summarizeTree)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
const node = value as Record<string, unknown>
|
||||
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
||||
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
||||
return {
|
||||
nodeKeys: Object.keys(node),
|
||||
entryKeys: Object.keys(entry),
|
||||
id: entry.id,
|
||||
parentId: entry.parentId,
|
||||
type: entry.type,
|
||||
role: message?.role,
|
||||
label: node.label,
|
||||
children: Array.isArray(node.children) ? summarizeTree(node.children) : []
|
||||
}
|
||||
}
|
||||
|
||||
function findUserNode(value: unknown): { id: string; parentId: string | null } | null {
|
||||
if (Array.isArray(value)) {
|
||||
let latest: { id: string; parentId: string | null } | null = null
|
||||
for (const child of value) {
|
||||
const found = findUserNode(child)
|
||||
if (found) latest = found
|
||||
}
|
||||
return latest
|
||||
}
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const node = value as Record<string, unknown>
|
||||
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
||||
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
||||
let latest: { id: string; parentId: string | null } | null = null
|
||||
if (entry.type === 'message' && message?.role === 'user' && typeof entry.id === 'string') {
|
||||
latest = { id: entry.id, parentId: typeof entry.parentId === 'string' ? entry.parentId : null }
|
||||
}
|
||||
const child = Array.isArray(node.children) ? findUserNode(node.children) : null
|
||||
return child ?? latest
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const exePath = process.env.YCODE_OMP_PATH ?? 'C:\\Users\\Administrator\\AppData\\Local\\Ygent\\omp.exe'
|
||||
if (!existsSync(exePath)) throw new Error(`OMP executable not found: ${exePath}`)
|
||||
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-protocol-'))
|
||||
const home = join(tempRoot, 'isolated-home')
|
||||
const sessionDir = join(tempRoot, 'sessions')
|
||||
const profile = `protocol-probe-${randomUUID().replaceAll('-', '')}`
|
||||
const sessionPath = join(sessionDir, 'probe.jsonl')
|
||||
const probeFile = join(tempRoot, 'probe.txt')
|
||||
const realProfilePath = resolve(homedir(), '.omp', 'profiles', profile)
|
||||
const realProfileRoot = resolve(homedir(), '.omp', 'profiles')
|
||||
const realProfileRelative = relative(realProfileRoot, realProfilePath)
|
||||
if (!realProfileRelative || realProfileRelative.startsWith(`..${sep}`) || resolve(realProfileRoot, realProfileRelative) !== realProfilePath) {
|
||||
throw new Error('Refusing an invalid isolated profile path')
|
||||
}
|
||||
if (existsSync(realProfilePath)) throw new Error('The unique probe profile unexpectedly exists')
|
||||
|
||||
await mkdir(home, { recursive: true })
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(probeFile, 'LOCAL_OMP_TOOL_SENTINEL\n', 'utf8')
|
||||
const profileAgentDir = join(home, '.omp', 'profiles', profile, 'agent')
|
||||
await mkdir(profileAgentDir, { recursive: true })
|
||||
const mock = await startMockProvider(probeFile)
|
||||
await writeFile(join(profileAgentDir, 'models.yml'), [
|
||||
'providers:',
|
||||
` ${PROVIDER_ID}:`,
|
||||
` baseUrl: ${mock.url}`,
|
||||
' api: openai-completions',
|
||||
' auth: none',
|
||||
' models:',
|
||||
` - id: ${MODEL_ID}`,
|
||||
' name: Local protocol probe',
|
||||
' reasoning: true',
|
||||
' input: [text, image]',
|
||||
' contextWindow: 64000',
|
||||
' maxTokens: 4096',
|
||||
''
|
||||
].join('\n'), 'utf8')
|
||||
|
||||
const runs: ProbeRun[] = []
|
||||
try {
|
||||
const version = spawnSync(exePath, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
env: makeIsolatedEnvironment(home, tempRoot, profile)
|
||||
})
|
||||
if (version.error) throw version.error
|
||||
if (version.status !== 0) throw new Error(`OMP --version failed with exit ${version.status}`)
|
||||
console.log(`OMP_VERSION: ${version.stdout.trim()}`)
|
||||
console.log(`ISOLATED_PROFILE: ${profile}`)
|
||||
console.log('PROVIDER_NETWORK: loopback only')
|
||||
|
||||
const first = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(first)
|
||||
const hostToolsFirst = await installProbeHostTool(first, '1')
|
||||
const stateResponse = await sendCommand(first, 'probe-state-1', { type: 'get_state' })
|
||||
console.log(`FIRST_READY: true`)
|
||||
console.log(`SET_HOST_TOOLS: ${JSON.stringify(hostToolsFirst ? summarizeFrame(hostToolsFirst) : null)}`)
|
||||
console.log(`GET_STATE: ${JSON.stringify(stateResponse ? summarizeFrame(stateResponse) : null)}`)
|
||||
|
||||
const image = {
|
||||
type: 'image' as const,
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/dXEAAAAASUVORK5CYII=',
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
id: 'probe-prompt-1',
|
||||
type: 'prompt',
|
||||
message: 'Reply exactly PROBE_OK.',
|
||||
images: [image]
|
||||
})}\n`)
|
||||
const promptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-1')
|
||||
const turnDone = await waitForFrame(first, (frame) =>
|
||||
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000)
|
||||
const toolTurnStart = first.frames.length
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
id: 'probe-prompt-tool',
|
||||
type: 'prompt',
|
||||
message: 'TOOL_PROBE: call the read tool for probe.txt, then reply exactly PROBE_OK.'
|
||||
})}\n`)
|
||||
const toolPromptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-tool')
|
||||
const hostToolCall = await waitForFrame(first, (frame) => frame.type === 'host_tool_call', 20_000, toolTurnStart)
|
||||
let hostToolResponseSent = false
|
||||
if (hostToolCall && typeof hostToolCall.id === 'string') {
|
||||
const args = hostToolCall.arguments && typeof hostToolCall.arguments === 'object'
|
||||
? hostToolCall.arguments as Record<string, unknown> : {}
|
||||
const targetPath = typeof args.path === 'string' ? args.path : probeFile
|
||||
const content = await readFile(targetPath, 'utf8')
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
type: 'host_tool_result',
|
||||
id: hostToolCall.id,
|
||||
result: { content: [{ type: 'text', text: content }] }
|
||||
})}\n`)
|
||||
hostToolResponseSent = true
|
||||
}
|
||||
const toolTurnDone = await waitForFrame(first, (frame) =>
|
||||
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000, toolTurnStart)
|
||||
const getMessages = await sendCommand(first, 'probe-messages-1', { type: 'get_messages' })
|
||||
const tree = await sendCommand(first, 'probe-tree-1', { type: 'get_tree' })
|
||||
const branchMessages = await sendCommand(first, 'probe-branch-messages-1', { type: 'get_branch_messages' })
|
||||
const stateAfterTurn = await sendCommand(first, 'probe-state-after-turn', { type: 'get_state' })
|
||||
|
||||
const entries = getMessages ? summarizeMessages(getMessages.data) : null
|
||||
const treeData = tree?.data && typeof tree.data === 'object' ? tree.data as Record<string, unknown> : {}
|
||||
const treeShape = summarizeTree(treeData.tree)
|
||||
const firstUserNode = findUserNode(treeData.tree)
|
||||
const fork = firstUserNode
|
||||
? await sendCommand(first, 'probe-fork-before-branch', { type: 'fork', entryId: firstUserNode.id })
|
||||
: null
|
||||
const stateAfterFork = await sendCommand(first, 'probe-state-after-fork', { type: 'get_state' })
|
||||
const forkStateData = stateAfterFork?.data && typeof stateAfterFork.data === 'object'
|
||||
? stateAfterFork.data as Record<string, unknown> : {}
|
||||
const forkSessionPath = typeof forkStateData.sessionFile === 'string' ? forkStateData.sessionFile : ''
|
||||
const relativeForkSession = forkSessionPath ? relative(tempRoot, resolve(forkSessionPath)) : ''
|
||||
const forkSessionInProbe = !!relativeForkSession && relativeForkSession !== '..' &&
|
||||
!relativeForkSession.startsWith(`..${sep}`) && !relativeForkSession.startsWith(sep)
|
||||
const messagesAfterFork = await sendCommand(first, 'probe-messages-after-fork', { type: 'get_messages' })
|
||||
const treeAfterFork = await sendCommand(first, 'probe-tree-after-fork', { type: 'get_tree' })
|
||||
const treeAfterForkData = treeAfterFork?.data && typeof treeAfterFork.data === 'object'
|
||||
? treeAfterFork.data as Record<string, unknown> : {}
|
||||
const forkData = fork?.data && typeof fork.data === 'object' ? fork.data as Record<string, unknown> : {}
|
||||
const stateBeforeForkData = stateAfterTurn?.data && typeof stateAfterTurn.data === 'object'
|
||||
? stateAfterTurn.data as Record<string, unknown> : {}
|
||||
|
||||
console.log(`PROMPT_ACK: ${JSON.stringify(promptAck ? summarizeFrame(promptAck) : null)}`)
|
||||
console.log(`FIRST_TURN_END_FRAME: ${JSON.stringify(turnDone ? summarizeFrame(turnDone) : null)}`)
|
||||
console.log(`TOOL_PROMPT_ACK: ${JSON.stringify(toolPromptAck ? summarizeFrame(toolPromptAck) : null)}`)
|
||||
console.log(`HOST_TOOL_CALL: ${JSON.stringify(hostToolCall ? summarizeFrame(hostToolCall) : null)}`)
|
||||
console.log(`HOST_TOOL_RESPONSE_SENT: ${hostToolResponseSent}`)
|
||||
console.log(`TOOL_TURN_END_FRAME: ${JSON.stringify(toolTurnDone ? summarizeFrame(toolTurnDone) : null)}`)
|
||||
console.log(`TURN_EVENTS: ${JSON.stringify(first.frames.filter((frame) => [
|
||||
'agent_start', 'message_start', 'message_update', 'message_end', 'tool_execution_start',
|
||||
'tool_execution_update', 'tool_execution_end', 'host_tool_call', 'turn_start', 'turn_end', 'agent_end', 'prompt_result'
|
||||
].includes(frame.type ?? '')).map(summarizeFrame))}`)
|
||||
console.log(`GET_MESSAGES_RESPONSE: ${JSON.stringify(getMessages ? {
|
||||
...summarizeFrame(getMessages),
|
||||
dataSummary: entries
|
||||
} : null)}`)
|
||||
console.log(`GET_TREE_RESPONSE: ${JSON.stringify(tree ? {
|
||||
...summarizeFrame(tree), leafId: treeData.leafId, tree: treeShape
|
||||
} : null)}`)
|
||||
console.log(`GET_BRANCH_MESSAGES_RESPONSE: ${JSON.stringify(branchMessages ? {
|
||||
...summarizeFrame(branchMessages), dataSummary: summarizeMessages(branchMessages.data)
|
||||
} : null)}`)
|
||||
console.log(`FORK_ENTRY_ID: ${JSON.stringify(firstUserNode?.id ?? null)}`)
|
||||
console.log(`FORK_RESPONSE: ${JSON.stringify(fork ? summarizeFrame(fork) : null)}`)
|
||||
console.log(`FORK_CANCELLED: ${forkData.cancelled}`)
|
||||
console.log(`FORK_STATE: ${JSON.stringify(stateAfterFork ? summarizeFrame(stateAfterFork) : null)}`)
|
||||
console.log(`FORK_SESSION_ID_CHANGED: ${stateBeforeForkData.sessionId !== forkStateData.sessionId}`)
|
||||
console.log(`FORK_SESSION_FILE_IS_PROBE_PATH: ${forkSessionInProbe}`)
|
||||
console.log(`FORK_SESSION_FILE_EXISTS: ${forkSessionInProbe ? existsSync(forkSessionPath) : false}`)
|
||||
console.log(`FORK_MESSAGES: ${JSON.stringify(messagesAfterFork ? summarizeMessages(messagesAfterFork.data) : null)}`)
|
||||
console.log(`FORK_TREE_LEAF_ID: ${treeAfterForkData.leafId ?? null}`)
|
||||
console.log(`TRANSCRIPT_AFTER_FORK: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
console.log(`STATE_AFTER_TURN: ${JSON.stringify(stateAfterTurn ? summarizeFrame(stateAfterTurn) : null)}`)
|
||||
console.log(`LOOPBACK_REQUESTS: ${JSON.stringify(mock.requests)}`)
|
||||
console.log(`TOOL_CALL_SENT: ${mock.toolCallSent}`)
|
||||
console.log(`TRANSCRIPT_AFTER_TURN: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
|
||||
await stopRun(first)
|
||||
const second = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(second)
|
||||
const hostToolsSecond = await installProbeHostTool(second, '2')
|
||||
const resumedState = await sendCommand(second, 'probe-state-2', { type: 'get_state' })
|
||||
const resumedMessages = await sendCommand(second, 'probe-messages-2', { type: 'get_messages' })
|
||||
console.log(`RESUME_READY: true`)
|
||||
console.log(`RESUME_SET_HOST_TOOLS: ${JSON.stringify(hostToolsSecond ? summarizeFrame(hostToolsSecond) : null)}`)
|
||||
console.log(`RESUME_GET_STATE: ${JSON.stringify(resumedState ? summarizeFrame(resumedState) : null)}`)
|
||||
console.log(`RESUME_GET_MESSAGES: ${JSON.stringify(resumedMessages ? {
|
||||
...summarizeFrame(resumedMessages),
|
||||
dataSummary: summarizeMessages(resumedMessages.data)
|
||||
} : null)}`)
|
||||
console.log(`RESUMED_TRANSCRIPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
let currentRun: ProbeRun = second
|
||||
|
||||
const resumeTree = await sendCommand(second, 'probe-tree-2', { type: 'get_tree' })
|
||||
const resumeTreeData = resumeTree?.data && typeof resumeTree.data === 'object' ? resumeTree.data as Record<string, unknown> : {}
|
||||
const resumeUserNode = findUserNode(resumeTreeData.tree)
|
||||
console.log(`RESUME_TREE: ${JSON.stringify(resumeTree ? {
|
||||
...summarizeFrame(resumeTree), leafId: resumeTreeData.leafId, tree: summarizeTree(resumeTreeData.tree)
|
||||
} : null)}`)
|
||||
|
||||
if (resumeUserNode) {
|
||||
const branch = await sendCommand(second, 'probe-branch-1', { type: 'branch', entryId: resumeUserNode.id })
|
||||
const branchState = await sendCommand(second, 'probe-state-after-branch', { type: 'get_state' })
|
||||
const branchHistory = await sendCommand(second, 'probe-messages-after-branch', { type: 'get_messages' })
|
||||
const branchTree = await sendCommand(second, 'probe-tree-after-branch', { type: 'get_tree' })
|
||||
const branchTreeData = branchTree?.data && typeof branchTree.data === 'object' ? branchTree.data as Record<string, unknown> : {}
|
||||
console.log(`BRANCH_ENTRY_ID: ${JSON.stringify(resumeUserNode.id)}`)
|
||||
console.log(`BRANCH_RESPONSE: ${JSON.stringify(branch ? summarizeFrame(branch) : null)}`)
|
||||
console.log(`BRANCH_STATE: ${JSON.stringify(branchState ? summarizeFrame(branchState) : null)}`)
|
||||
console.log(`BRANCH_MESSAGES: ${JSON.stringify(branchHistory ? {
|
||||
...summarizeFrame(branchHistory),
|
||||
dataSummary: summarizeMessages(branchHistory.data)
|
||||
} : null)}`)
|
||||
console.log(`BRANCH_TREE: ${JSON.stringify(branchTree ? {
|
||||
...summarizeFrame(branchTree), leafId: branchTreeData.leafId, tree: summarizeTree(branchTreeData.tree)
|
||||
} : null)}`)
|
||||
if (branch?.success === true) {
|
||||
await stopRun(second)
|
||||
const branchResume = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(branchResume)
|
||||
currentRun = branchResume
|
||||
const resumeHostTools = await installProbeHostTool(branchResume, 'after-branch')
|
||||
const messagesAfterBranchResume = await sendCommand(branchResume, 'probe-messages-after-branch-resume', { type: 'get_messages' })
|
||||
const treeAfterBranchResume = await sendCommand(branchResume, 'probe-tree-after-branch-resume', { type: 'get_tree' })
|
||||
const treeAfterBranchResumeData = treeAfterBranchResume?.data && typeof treeAfterBranchResume.data === 'object'
|
||||
? treeAfterBranchResume.data as Record<string, unknown> : {}
|
||||
console.log(`BRANCH_RESUME_READY: true`)
|
||||
console.log(`BRANCH_RESUME_SET_HOST_TOOLS: ${JSON.stringify(resumeHostTools ? summarizeFrame(resumeHostTools) : null)}`)
|
||||
console.log(`BRANCH_RESUME_MESSAGES: ${JSON.stringify(messagesAfterBranchResume ? summarizeMessages(messagesAfterBranchResume.data) : null)}`)
|
||||
console.log(`BRANCH_RESUME_TREE_LEAF_ID: ${treeAfterBranchResumeData.leafId ?? null}`)
|
||||
console.log(`TRANSCRIPT_AFTER_BRANCH_WITHOUT_PROMPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
}
|
||||
|
||||
const navigateTree = await sendLooseCommand(currentRun, 'probe-navigate-tree', {
|
||||
type: 'navigate_tree', entryId: resumeUserNode.id
|
||||
})
|
||||
const navigateToEntry = await sendLooseCommand(currentRun, 'probe-navigate-to-entry', {
|
||||
type: 'navigate_to_entry', entryId: resumeUserNode.id
|
||||
})
|
||||
const switchSession = await sendCommand(currentRun, 'probe-switch-session', {
|
||||
type: 'switch_session', sessionPath
|
||||
})
|
||||
const clear = await sendLooseCommand(currentRun, 'probe-clear', { type: 'clear' })
|
||||
console.log(`NAVIGATE_TREE_RESPONSE: ${JSON.stringify(navigateTree ? summarizeFrame(navigateTree) : null)}`)
|
||||
console.log(`NAVIGATE_TO_ENTRY_RESPONSE: ${JSON.stringify(navigateToEntry ? summarizeFrame(navigateToEntry) : null)}`)
|
||||
console.log(`SWITCH_SESSION_SAME_PATH: ${JSON.stringify(switchSession ? summarizeFrame(switchSession) : null)}`)
|
||||
console.log(`CLEAR_RESPONSE: ${JSON.stringify(clear ? summarizeFrame(clear) : null)}`)
|
||||
} else {
|
||||
console.log('BRANCH_ENTRY_ID: unavailable from get_tree parent relation')
|
||||
}
|
||||
await stopRun(currentRun)
|
||||
} finally {
|
||||
for (const run of runs) await stopRun(run)
|
||||
await new Promise<void>((resolveClose) => mock.server.close(() => resolveClose()))
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
if (existsSync(realProfilePath)) await rm(realProfilePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP protocol probe failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runOmpTurn } from '../src/main/agent/omp-turn'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
class Kernel extends EventEmitter {
|
||||
abortCalls = 0
|
||||
disposeCalls = 0
|
||||
rejectAbort = false
|
||||
async prompt(): Promise<{ type: string; success: boolean }> { return { type: 'response', success: true } }
|
||||
async abort(): Promise<void> { this.abortCalls++; if (this.rejectAbort) throw new Error('abort unavailable') }
|
||||
async dispose(): Promise<void> { this.disposeCalls++ }
|
||||
}
|
||||
function begin(kernel = new Kernel(), controller = new AbortController()) {
|
||||
const events: ChatEvent[] = []
|
||||
const outcome = runOmpTurn({ kernel: kernel as unknown as OmpKernel, runId: 'provider-error',
|
||||
userText: 'fixture', signal: controller.signal, emit: event => events.push(event) })
|
||||
.then(value => ({ value, error: undefined }), (error: Error) => ({ value: undefined, error }))
|
||||
return { kernel, controller, events, outcome }
|
||||
}
|
||||
const errorMessage = { role: 'assistant', stopReason: 'error', content: [], errorMessage: 'Unable to connect. Is the computer able to access the url?' }
|
||||
const emit = (kernel: Kernel, frame: Record<string, unknown>): void => { kernel.emit('event', frame) }
|
||||
const delay = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 15))
|
||||
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
||||
const result = await test.outcome
|
||||
assert.equal(result.error?.message, errorMessage.errorMessage)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
assert.equal(test.events.filter(event => event.type === 'error').length, 1)
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [errorMessage] })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: true })
|
||||
assert.equal(test.events.filter(event => ['done', 'cancelled'].includes(event.type)).length, 0)
|
||||
assert.equal(test.kernel.listenerCount('event'), 0)
|
||||
assert.equal(test.kernel.listenerCount('error'), 0)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'user' }, errorMessage] })
|
||||
assert.equal((await test.outcome).error?.message, errorMessage.errorMessage)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, errorMessage: 'Failed https://account:secret@example.invalid/v1?key=url-secret API Key: field-secret Authorization: Bearer bearer-secret sk-plainsecret123' } })
|
||||
const message = (await test.outcome).error!.message
|
||||
for (const secret of ['account', 'secret@example', 'url-secret', 'field-secret', 'bearer-secret', 'plainsecret123']) assert.equal(message.includes(secret), false)
|
||||
assert.match(message, /已隐藏/)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
test.controller.abort()
|
||||
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
||||
assert.equal((await test.outcome).value?.status, 'cancelled')
|
||||
assert.equal(test.events.some(event => event.type === 'error'), false)
|
||||
assert.equal(test.events.filter(event => event.type === 'cancelled').length, 1)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
let ended = false
|
||||
void test.outcome.then(() => { ended = true })
|
||||
// There is no first-byte deadline: long thinking and tool-level failures remain live.
|
||||
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking', contentIndex: 0 } })
|
||||
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, role: 'toolResult' } })
|
||||
emit(test.kernel, { type: 'tool_execution_start', toolCallId: 'tool', toolName: 'shell', args: {} })
|
||||
emit(test.kernel, { type: 'tool_execution_end', toolCallId: 'tool', toolName: 'shell', isError: true, result: { content: [{ type: 'text', text: 'command failed' }] } })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'assistant', stopReason: 'toolUse' }] })
|
||||
await delay()
|
||||
assert.equal(ended, false)
|
||||
assert.equal(test.kernel.abortCalls, 0)
|
||||
assert.equal(test.events.some(event => event.type === 'error'), false)
|
||||
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'recovered' } })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: true, messages: [errorMessage, { role: 'assistant', stopReason: 'stop' }] })
|
||||
const result = (await test.outcome).value!
|
||||
assert.equal(result.status, 'done')
|
||||
assert.equal(result.content, 'recovered')
|
||||
assert.equal(result.tools[0].status, 'error')
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
test.kernel.rejectAbort = true
|
||||
emit(test.kernel, { type: 'message_end', message: { role: 'assistant', stopReason: 'error' } })
|
||||
assert.equal((await test.outcome).error?.message, 'OMP 模型服务请求失败')
|
||||
await delay()
|
||||
assert.equal(test.kernel.disposeCalls, 1, 'failed abort disposes the sidecar instead of leaving automatic retries running')
|
||||
}
|
||||
console.log('omp-provider-error-regression: provider error, retry stop, sanitization, cancellation, long thinking, and tool error isolation passed (protocol fixtures; no real upstream request)')
|
||||
@@ -1,444 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import type { OmpFrame, OmpKernel } from '../src/main/omp'
|
||||
import { runOmpTurn, type OmpTurnResult } from '../src/main/agent/omp-turn'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
type Outcome<T> =
|
||||
| { kind: 'resolved'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| { kind: 'timeout' }
|
||||
|
||||
class FakeOmpKernel extends EventEmitter {
|
||||
promptCalls = 0
|
||||
abortCalls = 0
|
||||
ack: OmpFrame = { type: 'response', success: true }
|
||||
|
||||
prompt(_message: string): Promise<OmpFrame> {
|
||||
this.promptCalls++
|
||||
return Promise.resolve(this.ack)
|
||||
}
|
||||
|
||||
abort(): Promise<OmpFrame> {
|
||||
this.abortCalls++
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
|
||||
setHostTools(tools: Array<{ name: string }>): Promise<OmpFrame<{ toolNames: string[] }>> {
|
||||
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } })
|
||||
}
|
||||
|
||||
respondToHostTool(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
function listenerCounts(kernel: FakeOmpKernel): Record<'event' | 'error' | 'exit', number> {
|
||||
return {
|
||||
event: kernel.listenerCount('event'),
|
||||
error: kernel.listenerCount('error'),
|
||||
exit: kernel.listenerCount('exit')
|
||||
}
|
||||
}
|
||||
|
||||
function checkListeners(label: string, kernel: FakeOmpKernel, baseline: ReturnType<typeof listenerCounts>): void {
|
||||
const actual = listenerCounts(kernel)
|
||||
check(
|
||||
`${label}: event/error/exit listener counts return to baseline`,
|
||||
JSON.stringify(actual) === JSON.stringify(baseline),
|
||||
`baseline=${JSON.stringify(baseline)}, actual=${JSON.stringify(actual)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 250): Promise<Outcome<T>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<Outcome<T>>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs)
|
||||
})
|
||||
const settled = promise.then<Outcome<T>, Outcome<T>>(
|
||||
(value) => ({ kind: 'resolved', value }),
|
||||
(error: unknown) => ({ kind: 'rejected', error })
|
||||
)
|
||||
const outcome = await Promise.race([settled, timeout])
|
||||
if (timer) clearTimeout(timer)
|
||||
return outcome
|
||||
}
|
||||
|
||||
function turn(
|
||||
kernel: FakeOmpKernel,
|
||||
signal: AbortSignal,
|
||||
events: ChatEvent[]
|
||||
): Promise<OmpTurnResult> {
|
||||
return runOmpTurn({
|
||||
runId: 'run-regression',
|
||||
kernel: kernel as unknown as OmpKernel,
|
||||
userText: 'test prompt',
|
||||
signal,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
}
|
||||
|
||||
async function testTurnLifecycle(): Promise<void> {
|
||||
console.log('\n[1] OMP turn lifecycle')
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', {
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: 'hello' }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'agent_end resolves as done with accumulated text',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'done' && outcome.value.content === 'hello'
|
||||
)
|
||||
check('normal completion emits done', events.some((event) => event.type === 'done'))
|
||||
checkListeners('normal completion', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const controller = new AbortController()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, controller.signal, events)
|
||||
controller.abort()
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'abort followed by agent_end resolves as cancelled',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
|
||||
)
|
||||
check('abort requests kernel cancellation once', kernel.abortCalls === 1, `actual ${kernel.abortCalls}`)
|
||||
check('agent_end after abort emits cancelled', events.some((event) => event.type === 'cancelled'))
|
||||
checkListeners('agent_end cancellation', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'prompt_result', status: 'aborted' })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'aborted prompt_result resolves as cancelled',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
|
||||
)
|
||||
check('aborted prompt_result emits cancelled', events.some((event) => event.type === 'cancelled'))
|
||||
checkListeners('prompt_result cancellation', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
kernel.ack = { type: 'response', success: false, error: { message: 'ack rejected' } }
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a rejected prompt ack rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'ack rejected',
|
||||
outcome.kind === 'rejected' ? `rejection=${String(outcome.error)}` : `outcome=${outcome.kind}`
|
||||
)
|
||||
check('a rejected prompt ack emits error', events.some((event) => event.type === 'error'))
|
||||
checkListeners('prompt ack error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'prompt_result', status: 'error', error: { message: 'event rejected' } })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'an error prompt_result rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'event rejected'
|
||||
)
|
||||
check('an error prompt_result emits error', events.some((event) => event.type === 'error'))
|
||||
checkListeners('prompt_result error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('error', new Error('kernel error'))
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a kernel error rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'kernel error'
|
||||
)
|
||||
checkListeners('kernel error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('exit', 1)
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a kernel exit rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message.includes('OMP 进程已退出')
|
||||
)
|
||||
checkListeners('kernel exit', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, controller.signal, events)
|
||||
const outcome = await settleWithin(pending, 75)
|
||||
check(
|
||||
'a pre-aborted signal rejects with AbortError',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.name === 'AbortError',
|
||||
`outcome=${outcome.kind}`
|
||||
)
|
||||
check(
|
||||
'a pre-aborted signal sends neither prompt nor abort',
|
||||
kernel.promptCalls === 0 && kernel.abortCalls === 0,
|
||||
`prompt=${kernel.promptCalls}, abort=${kernel.abortCalls}`
|
||||
)
|
||||
if (outcome.kind === 'timeout') {
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
await pending.catch(() => undefined)
|
||||
}
|
||||
checkListeners('pre-aborted signal', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
interface IpcTestState {
|
||||
userData: string
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>
|
||||
kernel: EventEmitter | null
|
||||
promptCalls: number
|
||||
abortCalls: number
|
||||
rejectNextPrompt: boolean
|
||||
thinkingLevels: string[]
|
||||
}
|
||||
|
||||
async function testIpcCancellationPersistence(): Promise<void> {
|
||||
console.log('\n[2] IPC cancellation persistence')
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-regression-'))
|
||||
const state: IpcTestState = {
|
||||
userData: join(tempRoot, 'userData'),
|
||||
handlers: new Map(),
|
||||
kernel: null,
|
||||
promptCalls: 0,
|
||||
abortCalls: 0,
|
||||
rejectNextPrompt: false,
|
||||
thinkingLevels: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_TEST__?: IpcTestState }
|
||||
const previous = globalWithMock.__YCODE_OMP_TEST__
|
||||
globalWithMock.__YCODE_OMP_TEST__ = state
|
||||
let cleanupKernels: (() => Promise<void>) | undefined
|
||||
|
||||
try {
|
||||
const entry = join(process.cwd(), 'src', 'main', 'ipc.ts')
|
||||
const outfile = join(tempRoot, 'ipc-regression.mjs')
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
external: ['node-pty'],
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
banner: { js: "import { createRequire as createIpcTestRequire } from 'node:module'; const require = createIpcTestRequire(import.meta.url);" },
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
testTerminalHostPlugin,
|
||||
{
|
||||
name: 'ipc-regression-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? state.userData : state.userData,
|
||||
getName: () => 'YCode',
|
||||
getVersion: () => '0.1.0'
|
||||
};
|
||||
export const safeStorage = {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString()
|
||||
};
|
||||
export const powerSaveBlocker = { start: () => 1, stop: () => undefined, isStarted: () => true };
|
||||
export const ipcMain = {
|
||||
handle: (name, handler) => state.handlers.set(name, handler)
|
||||
};
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const shell = { openPath: async () => '' };
|
||||
export class BrowserWindow {}
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor() { super(); globalThis.__YCODE_OMP_TEST__.kernel = this; }
|
||||
setThinkingLevel(level) { globalThis.__YCODE_OMP_TEST__.thinkingLevels.push(level); return Promise.resolve(); }
|
||||
prompt() {
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
state.promptCalls++;
|
||||
if (state.rejectNextPrompt) {
|
||||
state.rejectNextPrompt = false;
|
||||
return Promise.resolve({ type: 'response', success: false, error: { message: 'mock OMP error' } });
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
setHostTools(tools) {
|
||||
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } });
|
||||
}
|
||||
respondToHostTool() { return Promise.resolve(); }
|
||||
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
|
||||
abort() {
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
state.abortCalls++;
|
||||
setImmediate(() => this.emit('event', { type: 'agent_end', yielded: true }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
dispose() { return Promise.resolve(); }
|
||||
}
|
||||
export const detectOmpPath = () => 'mock-omp';
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const ipc = await import(pathToFileURL(outfile).href)
|
||||
cleanupKernels = ipc.disposeOmpKernels
|
||||
;(ipc.registerIpc as (getWindow: () => unknown) => void)(() => ({
|
||||
webContents: { send: () => undefined }
|
||||
}))
|
||||
const invoke = (channel: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const handler = state.handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return Promise.resolve(handler({}, ...args))
|
||||
}
|
||||
|
||||
const session = (await invoke('session:create', 'cancel persistence')) as { id: string }
|
||||
const sent = (await invoke('chat:send', { sessionId: session.id, text: 'cancel this turn' })) as { runId: string }
|
||||
// Native kernel acquisition is asynchronous; exercise cancellation after prompt dispatch.
|
||||
for (let i = 0; i < 50 && state.promptCalls === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
const cancelled = (await invoke('chat:cancel', sent.runId)) as { cancelled: boolean }
|
||||
check('chat:cancel accepts the active run', cancelled.cancelled === true)
|
||||
|
||||
let messages: Array<{ role: string; status?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
messages = (await invoke('session:messages', session.id)) as Array<{ role: string; status?: string }>
|
||||
if (messages.at(-1)?.status !== 'streaming') break
|
||||
}
|
||||
check('the fake OMP kernel received one prompt and one abort', state.promptCalls === 1 && state.abortCalls === 1)
|
||||
check('a non-reasoning model explicitly disables native thinking before prompt', state.thinkingLevels[0] === 'off')
|
||||
check('cancelled assistant status is persisted through IPC', messages.at(-1)?.role === 'assistant' && messages.at(-1)?.status === 'cancelled', JSON.stringify(messages))
|
||||
|
||||
const sessionsFile = join(state.userData, 'sessions.json')
|
||||
const readStoredMessages = async (sessionId: string): Promise<Array<{ role: string; status?: string; error?: string }>> => {
|
||||
try {
|
||||
const stored = JSON.parse(await readFile(sessionsFile, 'utf8')) as {
|
||||
sessions: Array<{ id: string; messages: Array<{ role: string; status?: string; error?: string }> }>
|
||||
}
|
||||
return stored.sessions.find((item) => item.id === sessionId)?.messages ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
let diskMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
diskMessages = await readStoredMessages(session.id)
|
||||
if (diskMessages.at(-1)?.status === 'cancelled') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check('cancelled status is present on disk', diskMessages.at(-1)?.status === 'cancelled', JSON.stringify(diskMessages))
|
||||
|
||||
state.rejectNextPrompt = true
|
||||
const errorSession = (await invoke('session:create', 'error persistence')) as { id: string }
|
||||
await invoke('chat:send', { sessionId: errorSession.id, text: 'fail this turn' })
|
||||
let errorMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
errorMessages = (await invoke('session:messages', errorSession.id)) as Array<{ role: string; status?: string; error?: string }>
|
||||
if (errorMessages.at(-1)?.status === 'error') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check(
|
||||
'OMP error status and message are preserved in session memory',
|
||||
errorMessages.at(-1)?.status === 'error' && errorMessages.at(-1)?.error === 'mock OMP error',
|
||||
JSON.stringify(errorMessages)
|
||||
)
|
||||
let errorDiskMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
errorDiskMessages = await readStoredMessages(errorSession.id)
|
||||
if (errorDiskMessages.at(-1)?.status === 'error') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check(
|
||||
'OMP error status and message are preserved on disk',
|
||||
errorDiskMessages.at(-1)?.status === 'error' && errorDiskMessages.at(-1)?.error === 'mock OMP error',
|
||||
JSON.stringify(errorDiskMessages)
|
||||
)
|
||||
} finally {
|
||||
await cleanupKernels?.()
|
||||
globalWithMock.__YCODE_OMP_TEST__ = previous
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await testTurnLifecycle()
|
||||
await testIpcCancellationPersistence()
|
||||
console.log(failures === 0 ? '\nOMP regression checks passed.\n' : `\n${failures} OMP regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,293 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
interface FakeChildState {
|
||||
exePath: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
child: EventEmitter & {
|
||||
pid: number
|
||||
exitCode: number | null
|
||||
signalCode: string | null
|
||||
killed: boolean
|
||||
stdin: { write: (payload: string) => boolean; end: () => void }
|
||||
kill: () => boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface KernelTestState {
|
||||
children: FakeChildState[]
|
||||
readers: EventEmitter[]
|
||||
stdinWrites: string[]
|
||||
autoExitOnEnd: boolean
|
||||
stdinEnds: number
|
||||
readerCloses: number
|
||||
killCalls: number
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
async function buildKernelBundle(outfile: string): Promise<void> {
|
||||
await build({
|
||||
entryPoints: [join(process.cwd(), 'src', 'main', 'omp.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'omp-session-kernel-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^node:child_process$/ }, () => ({
|
||||
path: 'mock-child-process',
|
||||
namespace: 'omp-session-mock'
|
||||
}))
|
||||
pluginBuild.onLoad({ filter: /^mock-child-process$/, namespace: 'omp-session-mock' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
||||
class FakeChild extends EventEmitter {
|
||||
constructor(exePath, args, cwd) {
|
||||
super();
|
||||
this.pid = 100 + state.children.length;
|
||||
this.exitCode = null;
|
||||
this.signalCode = null;
|
||||
this.killed = false;
|
||||
this.stdin = {
|
||||
write: (payload) => {
|
||||
state.stdinWrites.push(payload);
|
||||
let command;
|
||||
try { command = JSON.parse(payload); } catch { return true; }
|
||||
if (typeof command.id === 'string' && command.type !== 'host_tool_result') {
|
||||
const reader = state.readers[state.readers.length - 1];
|
||||
const data = command.type === 'set_host_tools'
|
||||
? { toolNames: (command.tools || []).map((tool) => tool.name) }
|
||||
: command.type === 'get_branch_messages'
|
||||
? { messages: [{ entryId: 'user-entry-1', text: 'saved prompt' }] }
|
||||
: command.type === 'get_tree' ? { tree: [], leafId: null }
|
||||
: command.type === 'get_messages' ? { messages: [] } : {};
|
||||
setImmediate(() => reader?.emit('line', JSON.stringify({
|
||||
id: command.id, type: 'response', command: command.type, success: true, data
|
||||
})));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
end: () => {
|
||||
state.stdinEnds++;
|
||||
if (state.autoExitOnEnd) setTimeout(() => this.finish(0, null), 5);
|
||||
}
|
||||
};
|
||||
this.stdout = {};
|
||||
this.stderr = new EventEmitter();
|
||||
state.children.push({ exePath, args, cwd, child: this });
|
||||
}
|
||||
finish(code, signal) {
|
||||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||||
this.exitCode = code;
|
||||
this.signalCode = signal;
|
||||
this.emit('exit', code, signal);
|
||||
this.emit('close', code, signal);
|
||||
}
|
||||
kill() {
|
||||
this.killed = true;
|
||||
state.killCalls++;
|
||||
this.finish(null, 'SIGTERM');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export function spawn(exePath, args, options) {
|
||||
return new FakeChild(exePath, args, options.cwd);
|
||||
}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^node:readline$/ }, () => ({
|
||||
path: 'mock-readline',
|
||||
namespace: 'omp-session-mock'
|
||||
}))
|
||||
pluginBuild.onLoad({ filter: /^mock-readline$/, namespace: 'omp-session-mock' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
||||
export function createInterface() {
|
||||
const reader = new EventEmitter();
|
||||
reader.close = () => { state.readerCloses++; };
|
||||
state.readers.push(reader);
|
||||
return reader;
|
||||
}
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-session-regression-'))
|
||||
const state: KernelTestState = {
|
||||
children: [],
|
||||
readers: [],
|
||||
stdinWrites: [],
|
||||
autoExitOnEnd: true,
|
||||
stdinEnds: 0,
|
||||
readerCloses: 0,
|
||||
killCalls: 0
|
||||
}
|
||||
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_SESSION_TEST__?: KernelTestState }
|
||||
const previous = globalWithMock.__YCODE_OMP_SESSION_TEST__
|
||||
globalWithMock.__YCODE_OMP_SESSION_TEST__ = state
|
||||
|
||||
try {
|
||||
const outfile = join(tempRoot, 'omp-kernel-regression.mjs')
|
||||
await buildKernelBundle(outfile)
|
||||
const { OmpKernel, parseFirstProfileModel } = await import(pathToFileURL(outfile).href)
|
||||
|
||||
const yaml = [
|
||||
'providers:',
|
||||
' local-probe:',
|
||||
' api: openai-completions',
|
||||
' models:',
|
||||
' - id: probe-model',
|
||||
' name: Probe model',
|
||||
' second-provider:',
|
||||
' models:',
|
||||
' - id: second-model'
|
||||
].join('\n')
|
||||
check('profile model resolution returns provider/model, not profile/model',
|
||||
parseFirstProfileModel(yaml) === 'local-probe/probe-model')
|
||||
check('model resolution returns null when models.yml has no configured provider model',
|
||||
parseFirstProfileModel('providers:\n empty-provider:\n models: []') === null)
|
||||
|
||||
const sessionPath = join(tempRoot, 'native', 'session-one.jsonl')
|
||||
const selectedModel = 'local-probe/probe-model'
|
||||
const persistent = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
||||
const persistentStart = persistent.start()
|
||||
check('session parent directory is created before spawn', await waitFor(() => state.children.length === 1) &&
|
||||
(await stat(dirname(sessionPath))).isDirectory())
|
||||
check('persistent start reaches the ready frame', state.readers.length === 1)
|
||||
state.readers[0]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await persistentStart
|
||||
const persistentArgs = state.children[0]?.args ?? []
|
||||
check('persistent mode passes the configured provider/model selection',
|
||||
persistentArgs.includes(`--model=${selectedModel}`))
|
||||
check('built-in OMP tools are disabled by default so execution stays behind the host broker',
|
||||
persistentArgs.includes('--no-tools'))
|
||||
check('persistent mode passes the native resume path and session directory',
|
||||
persistentArgs.includes(`--resume=${sessionPath}`) && persistentArgs.includes(`--session-dir=${dirname(sessionPath)}`))
|
||||
check('persistent mode omits --no-session', !persistentArgs.includes('--no-session'))
|
||||
|
||||
const hostToolsAck = await persistent.setHostTools([{
|
||||
name: 'read',
|
||||
description: 'Read workspace text',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }
|
||||
}])
|
||||
const branchMessages = await persistent.getBranchMessages()
|
||||
const branchAck = await persistent.branch('user-entry-1')
|
||||
await persistent.respondToHostTool('host-request-1', 'tool result')
|
||||
const image = { type: 'image' as const, data: 'c21hbGw=', mimeType: 'image/png' }
|
||||
const promptAck = await persistent.prompt('inspect this image', [image])
|
||||
const writtenFrames = state.stdinWrites.map((payload) => JSON.parse(payload) as Record<string, unknown>)
|
||||
const promptFrame = writtenFrames.find((frame) => frame.type === 'prompt')
|
||||
const hostResultFrame = writtenFrames.find((frame) => frame.type === 'host_tool_result')
|
||||
check('host tools, branch history, and images use their typed RPC helpers',
|
||||
hostToolsAck.success === true && branchMessages.data.messages[0]?.entryId === 'user-entry-1' &&
|
||||
branchAck.success === true && promptAck.success === true &&
|
||||
Array.isArray(promptFrame?.images) &&
|
||||
JSON.stringify((promptFrame.images as unknown[])[0]) === JSON.stringify(image) &&
|
||||
hostResultFrame?.id === 'host-request-1')
|
||||
|
||||
let sawExit = false
|
||||
persistent.once('exit', () => { sawExit = true })
|
||||
await persistent.dispose()
|
||||
check('dispose ends stdin and waits until the child exits', state.stdinEnds === 1 && sawExit)
|
||||
check('kernel event listeners are removed after child exit', persistent.eventNames().length === 0)
|
||||
|
||||
const ephemeral = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, selectedModel)
|
||||
const ephemeralStart = ephemeral.start()
|
||||
check('ephemeral start creates a second child', await waitFor(() => state.children.length === 2))
|
||||
state.readers[1]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await ephemeralStart
|
||||
const ephemeralArgs = state.children[1]?.args ?? []
|
||||
check('three-argument constructors keep --no-session behavior',
|
||||
ephemeralArgs.includes('--no-session') && !ephemeralArgs.some((arg) => arg.startsWith('--resume=')) &&
|
||||
ephemeralArgs.includes('--no-tools'))
|
||||
await ephemeral.dispose()
|
||||
|
||||
const missingModel = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, null)
|
||||
let missingModelError: unknown
|
||||
try {
|
||||
await missingModel.start()
|
||||
} catch (error) {
|
||||
missingModelError = error
|
||||
}
|
||||
check('a profile without provider/model fails before spawn',
|
||||
missingModelError instanceof Error && missingModelError.message.includes('provider/model') && state.children.length === 2)
|
||||
|
||||
state.autoExitOnEnd = false
|
||||
const forceKilled = new OmpKernel(process.execPath, tempRoot, 'ycode', join(tempRoot, 'native', 'session-three.jsonl'), selectedModel)
|
||||
const forceKillStart = forceKilled.start()
|
||||
check('force-kill case starts a third child', await waitFor(() => state.children.length === 3))
|
||||
state.readers[2]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await forceKillStart
|
||||
check('built-in OMP tools remain disabled for every kernel session',
|
||||
state.children[2]?.args.includes('--no-tools') === true)
|
||||
const killStartedAt = Date.now()
|
||||
const keepAlive = setTimeout(() => undefined, 1800)
|
||||
await forceKilled.dispose()
|
||||
clearTimeout(keepAlive)
|
||||
const killDelay = Date.now() - killStartedAt
|
||||
check('stalled child is killed after the 1500ms grace period',
|
||||
state.killCalls === 1 && state.children[2]?.child.killed === true && killDelay >= 1400 && killDelay < 2500,
|
||||
`${killDelay}ms`)
|
||||
|
||||
const recoverable = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
||||
const firstStart = recoverable.start()
|
||||
check('recoverable kernel creates a fresh sidecar', await waitFor(() => state.children.length === 4))
|
||||
state.readers[3]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await firstStart
|
||||
state.children[3]?.child.finish(1, null)
|
||||
const recoveredStart = recoverable.start()
|
||||
check('kernel can restart after its sidecar exits', await waitFor(() => state.children.length === 5))
|
||||
state.readers[4]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await recoveredStart
|
||||
const recoveredArgs = state.children[4]?.args ?? []
|
||||
check('restarted sidecar reuses the same resume path and provider/model',
|
||||
recoveredArgs.includes(`--resume=${sessionPath}`) && recoveredArgs.includes(`--model=${selectedModel}`))
|
||||
await recoverable.dispose()
|
||||
|
||||
console.log(failures === 0 ? '\nOMP session regression checks passed.\n' : `\n${failures} OMP session regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
} finally {
|
||||
globalWithMock.__YCODE_OMP_SESSION_TEST__ = previous
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP session regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { listSandboxToolchains, importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Bundled Git verification requires real Windows isolation')
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-bundled-git-'))
|
||||
const workspace = join(fixture, 'workspace \u4e2d\u6587'), stateRoot = join(fixture, 'data'), resources = resolve(process.env.YCODE_TEST_BUNDLED_RESOURCES ?? 'resources/sandbox/win32-x64')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'input.txt'), 'baseline\n')
|
||||
const lock = JSON.parse(await fs.readFile(resolve('resources/sandbox/git-runtime.lock.json'), 'utf8'))
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, runtimes: ['git'], sessionId: 'bundled-git-session', runId: 'bundled-git-run', toolCallId: 'bundled-git-call', approve: async () => true }
|
||||
const configure = (resourceDirectory = resources, useBundledGit = true): void => configureSandbox({ stateRoot, resourceDirectory, useBundledGit })
|
||||
const gitView = async () => (await listSandboxToolchains()).find(item => item.kind === 'git')!
|
||||
const { verifyBundledGit } = await import(pathToFileURL(resolve('scripts/vendor-sandbox-git.mjs')).href)
|
||||
const originalPath = process.env.PATH
|
||||
try {
|
||||
configure()
|
||||
const view = await gitView()
|
||||
check('fresh userData exposes the pinned bundled Git without registration', view.state === 'registered' && view.source === 'bundled' && view.version === lock.version)
|
||||
check('availability queries do not create a user Git registration', !await fs.stat(join(stateRoot, 'toolchains/git.json')).catch(() => null))
|
||||
const component = JSON.parse(await fs.readFile(join(resources, 'manifest.json'), 'utf8'))
|
||||
const bundle = await verifyBundledGit(join(resources, 'git'))
|
||||
check('native component pins the complete bundled runtime manifest', component.bundledGit.manifestSha256 === bundle.manifestSha256 && component.bundledGit.archiveSha256 === lock.archive.sha256)
|
||||
const notices = await fs.readFile(join(resources, 'git/files/THIRD_PARTY_NOTICES.txt'), 'utf8')
|
||||
check('license, copyright and source provenance travel with the runtime', notices.includes(lock.releaseUrl) && notices.includes('GPL-2.0') && (await fs.readFile(join(resources, 'git/files/LICENSE.txt'), 'utf8')).includes('GNU GENERAL PUBLIC LICENSE'))
|
||||
process.env.PATH = ''
|
||||
let result = await executeSandboxCommand('git --version && git status --porcelain=v1', context)
|
||||
check('real AppContainer Git works with empty host PATH and no user registry', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.toolchains?.[0].id === view.id && result.data.git?.persistence === 'unchanged')
|
||||
result = await executeSandboxCommand('echo bundled>bundled.txt && git add bundled.txt && git commit -q -m bundled-offline-commit', context)
|
||||
check('bundled Git stages and commits through the existing approval boundary', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'committed' && (await fs.readFile(join(workspace, 'bundled.txt'), 'utf8')).trim() === 'bundled')
|
||||
result = await executeSandboxCommand('git log -1 --oneline && git status --porcelain=v1', context)
|
||||
check('a later command sees the saved bundled-Git commit', result.ok && result.data.stdout.includes('bundled-offline-commit') && result.data.git?.persistence === 'unchanged')
|
||||
const copied = join(fixture, 'app resources \u5b89\u88c5/sandbox/win32-x64')
|
||||
await fs.cp(resources, copied, { recursive: true, errorOnExist: true, force: false })
|
||||
configure(copied)
|
||||
result = await executeSandboxCommand('git --version && git log -1 --oneline', context)
|
||||
check('copied application resources work at a space/Unicode installation path', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.stdout.includes('bundled-offline-commit'))
|
||||
const override = await importSandboxToolchain('git', join(copied, 'git/files/git.exe'))
|
||||
check('a deliberate imported override takes precedence over the bundled runtime', (await gitView()).source === 'imported' && (await gitView()).id === override.id && override.id !== view.id)
|
||||
const overrideIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
|
||||
const overrideImage = join(stateRoot, 'toolchains/objects', overrideIdentity.id, 'files/git.exe')
|
||||
await fs.appendFile(overrideImage, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
|
||||
check('a damaged explicit override is rejected without silently switching sources', (await gitView()).source === 'imported')
|
||||
const alias = join(stateRoot, 'toolchains/git.json'), aliasBytes = await fs.readFile(alias)
|
||||
await fs.writeFile(alias, '{}')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /引用无效/)
|
||||
check('an invalid user reference does not activate a bundled fallback', (await gitView()).state === 'invalid')
|
||||
await fs.writeFile(alias, aliasBytes); await fs.unlink(alias)
|
||||
check('removing the optional override restores the application default', (await gitView()).source === 'bundled' && (await gitView()).id === view.id)
|
||||
const image = join(copied, 'git/files/git.exe'), imageBytes = await fs.readFile(image)
|
||||
await fs.appendFile(image, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
|
||||
await assert.rejects(verifyBundledGit(join(copied, 'git')), /digest mismatch/)
|
||||
check('tampered bundled executable fails runtime and packaging checks', true)
|
||||
await fs.writeFile(image, imageBytes)
|
||||
const manifestPath = join(copied, 'git/manifest.json'), manifestBytes = await fs.readFile(manifestPath)
|
||||
await fs.appendFile(manifestPath, ' ')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /内置 Git 清单摘要不匹配/)
|
||||
await assert.rejects(verifyBundledGit(join(copied, 'git')), /manifest digest mismatch/)
|
||||
check('a bundled manifest replacement is rejected against the component pin', (await gitView()).state === 'invalid')
|
||||
await fs.writeFile(manifestPath, manifestBytes)
|
||||
await fs.rename(manifestPath, `${manifestPath}.missing`)
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /ENOENT/)
|
||||
check('missing bundled files never fall back to the system Git installation', (await gitView()).state === 'invalid')
|
||||
result = await executeSandboxCommand('echo SHELL_STILL_ISOLATED', { ...context, runtimes: [] })
|
||||
check('a Git component failure does not turn shell execution into a host fallback', result.ok && result.data.isolated && result.data.stdout.includes('SHELL_STILL_ISOLATED') && (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'unavailable'))
|
||||
await fs.rename(`${manifestPath}.missing`, manifestPath)
|
||||
configure(copied, false)
|
||||
check('a host configuration can explicitly disable bundled Git without granting host PATH', (await gitView()).state === 'missing')
|
||||
configure(copied)
|
||||
check('restored resources expose the original pinned identity', (await gitView()).id === view.id && (await verifyBundledGit(join(copied, 'git'))).manifestSha256 === bundle.manifestSha256)
|
||||
process.env.PATH = originalPath
|
||||
const badArchive = join(fixture, 'bad.zip'); await fs.writeFile(badArchive, 'not-an-archive')
|
||||
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', badArchive, '--offline', '--output', join(fixture, 'bad-output/git')], { windowsHide: true, stdio: 'pipe' }), /Official MinGit archive digest mismatch/)
|
||||
check('the explicit vendor command rejects archives with a wrong digest before extraction', true)
|
||||
const unrelated = join(fixture, 'unrelated/git'); await fs.mkdir(unrelated, { recursive: true }); await fs.writeFile(join(unrelated, 'user-file.txt'), 'preserve-user-data')
|
||||
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--offline', '--output', unrelated], { windowsHide: true, stdio: 'pipe' }), /Refusing to replace an existing directory/)
|
||||
check('vendoring refuses to move an unrelated existing output directory', await fs.readFile(join(unrelated, 'user-file.txt'), 'utf8') === 'preserve-user-data')
|
||||
if (process.env.YCODE_TEST_MINGIT_ARCHIVE) {
|
||||
const output = join(fixture, 'reproducible/git')
|
||||
execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', resolve(process.env.YCODE_TEST_MINGIT_ARCHIVE), '--offline', '--output', output], { windowsHide: true, stdio: 'pipe', timeout: 60_000 })
|
||||
check('the pinned archive produces an identical manifest in an offline rebuild', (await verifyBundledGit(output)).manifestSha256 === bundle.manifestSha256)
|
||||
}
|
||||
console.log(`SANDBOX_BUNDLED_GIT_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, resources, version: lock.version, scope: 'Real AppContainer execution using bundled Git with no user registration and empty host PATH; copied/actual packaged resources when supplied.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_BUNDLED_GIT_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { commitChanges, restoreTransaction, digest } from '../src/main/sandbox/transactions'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Directory integration requires real Windows native execution')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-directories-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const context = { workspaceRoot: workspace, sessionId: 'directory-session', runId: 'directory-run', toolCallId: 'directory-call', approve: async () => true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
let approvals = 0
|
||||
let result = await executeSandboxCommand('mkdir empty\\nested', { ...context, approve: async () => { approvals++; return true } })
|
||||
check('empty directory tree commits after one batch approval', result.ok && approvals === 1 && result.data.changes.filter(change => change.operation === 'mkdir').length === 2 && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
|
||||
const manifest = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', result.data.transactionId!, 'manifest.json'), 'utf8'))
|
||||
check('directory transaction persists a versioned recovery journal', manifest.version === 3 && manifest.directories.length === 2 && /^[a-f0-9]{16}:[a-f0-9]{32}$/.test(manifest.workspaceIdentity))
|
||||
await restoreTransaction(result.data.transactionId!, workspace); await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('directory creation recovery removes children and parents and is idempotent', !await fs.stat(join(workspace, 'empty')).catch(() => null))
|
||||
result = await executeSandboxCommand('mkdir denied', { ...context, approve: async () => false })
|
||||
check('directory creation rejection leaves no host directory', !result.ok && !await fs.stat(join(workspace, 'denied')).catch(() => null))
|
||||
await fs.mkdir(join(workspace, 'original/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'original/sub/data.bin'), Buffer.from([0, 1, 127, 255]))
|
||||
result = await executeSandboxCommand('rmdir /s /q original', context)
|
||||
check('recursive directory deletion records files and directories in one transaction', result.ok && result.data.changes.some(change => change.operation === 'delete') && result.data.changes.filter(change => change.operation === 'rmdir').length === 2 && !await fs.stat(join(workspace, 'original')).catch(() => null))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('recursive deletion recovery recreates original directories and exact binary bytes', (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])))
|
||||
result = await executeSandboxCommand('move original moved', context)
|
||||
check('directory rename is represented by complete transactional tree changes', result.ok && !await fs.stat(join(workspace, 'original')).catch(() => null) && (await fs.stat(join(workspace, 'moved/sub'))).isDirectory())
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('directory rename restores its original tree and removes the new tree', !await fs.stat(join(workspace, 'moved')).catch(() => null) && (await fs.stat(join(workspace, 'original/sub'))).isDirectory())
|
||||
const conflict = await executeSandboxCommand('rmdir /s /q original', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'original/external.txt'), 'external'); return true } }).then(() => null, error => error as Error)
|
||||
check('approval-time additions reject the full directory deletion before mutation', !!conflict && (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])) && await fs.readFile(join(workspace, 'original/external.txt'), 'utf8') === 'external')
|
||||
await fs.unlink(join(workspace, 'original/external.txt'))
|
||||
await fs.writeFile(join(workspace, 'original/.env'), 'DUMMY_CREDENTIAL')
|
||||
await assert.rejects(executeSandboxCommand('rmdir /s /q original', context), /未授权|新增内容/)
|
||||
check('excluded credentials inside a deleted directory are protected by host preflight', await fs.readFile(join(workspace, 'original/.env'), 'utf8') === 'DUMMY_CREDENTIAL' && (await fs.stat(join(workspace, 'original/sub/data.bin'))).isFile())
|
||||
await fs.unlink(join(workspace, 'original/.env'))
|
||||
result = await executeSandboxCommand('mkdir restored-conflict & echo generated>restored-conflict\\file.txt', context)
|
||||
await fs.writeFile(join(workspace, 'restored-conflict/external.txt'), 'external')
|
||||
await assert.rejects(restoreTransaction(result.data.transactionId!, workspace), /未授权|新增内容/)
|
||||
check('directory recovery refuses to remove later external additions', (await fs.readFile(join(workspace, 'restored-conflict/file.txt'), 'utf8')).trim() === 'generated' && await fs.readFile(join(workspace, 'restored-conflict/external.txt'), 'utf8') === 'external')
|
||||
await fs.unlink(join(workspace, 'restored-conflict/external.txt')); await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('recovery succeeds after the external conflict is explicitly resolved', !await fs.stat(join(workspace, 'restored-conflict')).catch(() => null))
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
let guarded = 0
|
||||
const failed = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context, undefined,
|
||||
{ beforeApply: async () => undefined, check: async () => { if (++guarded === 3) throw new Error('injected policy revocation') } }, [{ path: 'compensated-empty', before: false, after: true }]).then(() => null, error => error as Error & { recoveryRequired?: boolean })
|
||||
check('mid-commit failure compensates both file writes and created directories', !!failed && !failed.recoveryRequired && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before' && !await fs.stat(join(workspace, 'compensated-empty')).catch(() => null))
|
||||
console.log(`SANDBOX_DIRECTORY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Native NTFS transactions pin root/ancestors and use relative handle operations. Rename handoff is not a single atomic swap. ACL/timestamp parity, power-loss durability and Windows recycle-bin semantics remain pending.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_DIRECTORY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { executeTool, toolSpecs } from '../src/main/agent/tools'
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'ycode-host-tools-'))
|
||||
let approvals = 0
|
||||
const context = {
|
||||
workspaceRoot: workspace,
|
||||
permissionMode: 'default-sandbox' as const,
|
||||
approve: async () => {
|
||||
approvals += 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const specs = toolSpecs()
|
||||
const names = specs.map(spec => spec.function.name)
|
||||
assert.equal(names.includes('sandbox_workspace'), false)
|
||||
assert.equal(names.includes('sandbox_git'), false)
|
||||
const command = specs.find(spec => spec.function.name === 'execute_command')
|
||||
const properties = command?.function.parameters.properties as Record<string, unknown> | undefined
|
||||
assert.deepEqual(Object.keys(properties ?? {}), ['command'])
|
||||
|
||||
const written = await executeTool('write_file', { path: 'nested/file.txt', content: 'first' }, context)
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'first')
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const edited = await executeTool('edit_file', { path: 'nested/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
assert.equal(edited.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'second')
|
||||
|
||||
const commandResult = await executeTool('execute_command', {
|
||||
command: `${JSON.stringify(process.execPath)} -e "require('node:fs').writeFileSync('host-command.txt','host')"`
|
||||
}, context)
|
||||
assert.equal(commandResult.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'host-command.txt'), 'utf8'), 'host')
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('windows-appcontainer'), false)
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('sandbox'), false)
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const outside = await executeTool('read_file', { path: '../outside.txt' }, context)
|
||||
assert.equal(outside.ok, false)
|
||||
assert.match(outside.content, /工作区/)
|
||||
console.log('sandbox-disabled-regression: host tools are active and legacy sandbox modes are inert')
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { release, arch } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('P0 mechanisms must be tested on Windows')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-feasibility-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace)
|
||||
const probe = resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), registryId = randomUUID()
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await fs.copyFile(probe, join(workspace, 'sandbox-probe.exe'))
|
||||
const checks: Array<{ label: string; pass: boolean }> = [], observations: Record<string, unknown> = { os: release(), architecture: arch() }
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
function host(args: string[]): Promise<{ code: number | null; stdout: string }> {
|
||||
return new Promise((done, reject) => {
|
||||
const child = spawn(probe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''
|
||||
const timeout = setTimeout(() => child.kill(), 8000)
|
||||
child.stdout.on('data', chunk => { stdout += chunk }); child.stderr.on('data', chunk => { stdout += chunk })
|
||||
child.once('error', error => { clearTimeout(timeout); reject(error) }); child.once('close', code => { clearTimeout(timeout); done({ code, stdout }) })
|
||||
})
|
||||
}
|
||||
try {
|
||||
observations.host = JSON.parse((await host(['--host-facts'])).stdout)
|
||||
const created = await host(['--registry-create', registryId])
|
||||
check('registry fixture is created only under a UUID-owned test key', created.code === 0)
|
||||
const before = await host(['--registry-read', registryId])
|
||||
check('host positive control reads the exact fake registry value', before.code === 0 && before.stdout.includes('1193046'))
|
||||
const context = { workspaceRoot: workspace, runId: 'p0-mechanisms', toolCallId: 'registry' }
|
||||
const read = await executeSandboxCommand(`sandbox-probe.exe --registry-read ${registryId}`, context)
|
||||
const write = await executeSandboxCommand(`sandbox-probe.exe --registry-write ${registryId}`, context)
|
||||
observations.registry = { read: { ok: read.ok, stdout: read.data.stdout }, write: { ok: write.ok, stdout: write.data.stdout } }
|
||||
check('AppContainer cannot read the host-owned registry fixture value', !read.ok && !read.data.stdout.includes('1193046'))
|
||||
const after = await host(['--registry-read', registryId])
|
||||
check('AppContainer registry writes cannot change the host fixture', after.code === 0 && after.stdout === before.stdout)
|
||||
check('invalid registry identities are rejected without arbitrary key access', (await host(['--registry-create', '../outside'])).code === 2)
|
||||
const directory = join(fixture, 'locked'), renamed = join(fixture, 'renamed'), ready = join(fixture, 'ready'), unlock = join(fixture, 'unlock')
|
||||
await fs.mkdir(directory); await fs.writeFile(join(directory, 'sample.txt'), 'test-only')
|
||||
const held = host(['--hold-directory', directory, ready, unlock])
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(ready).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
check('native directory handle acquires its test-only object before replacement attempt', !!await fs.stat(ready).catch(() => null))
|
||||
const replacement = await fs.rename(directory, renamed).then(() => null, error => error as NodeJS.ErrnoException)
|
||||
observations.directoryReplacement = { error: replacement?.code ?? null }
|
||||
const blocked = !!replacement && ['EPERM', 'EACCES', 'EBUSY'].includes(replacement.code ?? '')
|
||||
await fs.writeFile(unlock, 'release')
|
||||
check('native directory-handle probe exits and releases ownership', (await held).code === 0)
|
||||
check('no-delete-sharing directory handle blocks replacement while held', blocked)
|
||||
await fs.rename(directory, renamed)
|
||||
check('positive control can rename the directory after handle release', await fs.readFile(join(renamed, 'sample.txt'), 'utf8') === 'test-only')
|
||||
if (process.argv.includes('--probe-pipe')) {
|
||||
const pipeId = randomUUID(), runs = join(fixture, 'state/runs')
|
||||
const client = executeSandboxCommand(`sandbox-probe.exe --pipe-client ${pipeId}`, { ...context, toolCallId: 'p0-pipe', timeoutMs: 6000 })
|
||||
let profile: string | undefined
|
||||
for (let attempt = 0; attempt < 100 && !profile; attempt++) {
|
||||
for (const entry of await fs.readdir(runs)) {
|
||||
const record = await fs.readFile(join(runs, entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
|
||||
if (record?.identity?.toolCallId === 'p0-pipe') profile = record.profile
|
||||
}
|
||||
if (!profile) await new Promise(done => setTimeout(done, 10))
|
||||
}
|
||||
assert.ok(profile, 'pipe scenario request must provide a host-owned profile identity')
|
||||
const server = host(['--pipe-server', pipeId, profile])
|
||||
const clientResult = await client, serverResult = await server
|
||||
observations.authenticatedPipe = { client: { ok: clientResult.ok, stdout: clientResult.data.stdout }, server: serverResult }
|
||||
const available = clientResult.ok && clientResult.data.stdout.includes('BROKER_PONG') && serverResult.code === 0 && serverResult.stdout.includes('PIPE_AUTHENTICATED')
|
||||
observations.brokerPipeAvailable = available
|
||||
if (available) check('host-scoped broker pipe accepts and authenticates the actual AppContainer client', true)
|
||||
else {
|
||||
check('unavailable broker transport cannot enable host execution or writeback', !clientResult.ok && clientResult.data.isolated && clientResult.data.sandbox === 'windows-appcontainer' && clientResult.data.writeback === 'not-applied' && !clientResult.data.changes.length)
|
||||
console.log('SANDBOX_PIPE_UNAVAILABLE: experimental transport remains gated; see observations')
|
||||
if (process.argv.includes('--require-pipe')) throw new Error('Authenticated broker pipe feasibility has not passed')
|
||||
}
|
||||
}
|
||||
console.log(`SANDBOX_FEASIBILITY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
const cleanup = await host(['--registry-delete', registryId]).catch(error => ({ code: -1, stdout: String(error) }))
|
||||
observations.registryCleanup = cleanup
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, observations, fixture, limitations: [
|
||||
'Registry results describe the current AppContainer and fake HKCU key only; no configurable registry policy is implemented.',
|
||||
'Holding one directory is a candidate primitive, not proof of safe ancestor traversal or a complete file broker.',
|
||||
'The pipe probe exchanges a fixed echo only; it does not implement operations, policy dispatch, queue bounds or descendant Job membership authentication.',
|
||||
'Controlled egress, descendant program policy, restricted ConPTY and hard disk quotas require further prototypes.',
|
||||
'This host permission level and OS are the only tested environment; no original WorkBuddy execution occurred.'
|
||||
] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_FEASIBILITY_REPORT ${join(fixture, 'report.json')}`)
|
||||
assert.equal(cleanup.code, 0, 'test registry key must be cleaned')
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { effectiveSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { createGitControl } from '../src/main/sandbox/git-control'
|
||||
import { captureHostGit } from '../src/main/sandbox/git-host-source'
|
||||
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-complete-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe')
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
function hostGit(...args: string[]): string {
|
||||
return execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'commit.gpgSign=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args],
|
||||
{ cwd: workspace, encoding: 'utf8', windowsHide: true, env: { SystemRoot: process.env.SystemRoot, PATH: `${resolve('resources/sandbox/win32-x64/git/files')};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' } })
|
||||
}
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'complete-session', runId: 'complete-run', toolCallId: 'complete-call', approve: async () => { approvals++; return true } }
|
||||
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent', preserveGitConflicts: true })
|
||||
async function gitRun(argv: string[]) {
|
||||
const result = await executeTool('sandbox_git', { action: 'run', argv }, context)
|
||||
await fs.writeFile(join(fixture, 'last-tool-result.json'), JSON.stringify(result, null, 2))
|
||||
return result
|
||||
}
|
||||
try {
|
||||
const policy = await effectiveSandboxPolicy(workspace), control = await createGitControl(policy)
|
||||
try {
|
||||
check('native parent-monitored host-only control runs the bundled Git', (await control.run(['--version'])).stdout.toString().includes('git version'))
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'initial\n')
|
||||
hostGit('add', '.'); hostGit('commit', '-q', '-m', 'real-host-history')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'staged\n'); hostGit('add', 'tracked.txt')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'unstaged\n')
|
||||
hostGit('config', 'credential.helper', '!echo SHOULD_NOT_RUN')
|
||||
const snapshot = await captureHostGit(policy, control)
|
||||
check('native metadata capture retains real HEAD, index and object history', snapshot.repository.has('HEAD') && snapshot.repository.has('index') && snapshot.files.some(file => file.path.startsWith('objects/')))
|
||||
check('host executable configuration is excluded from the mirror', !snapshot.repository.get('config')!.toString().includes('SHOULD_NOT_RUN'))
|
||||
} finally { await control.close() }
|
||||
const imported = await gitHostAction('import', context)
|
||||
check('explicit import is approval-bound and creates a session host mirror', imported.state === 'ready' && approvals === 1 && imported.pendingMetadata === false)
|
||||
let result = await run('git log -1 --oneline && git diff --cached && git diff')
|
||||
check('actual isolated Git sees host history and distinguishes staged and unstaged changes', result.ok && result.data.git?.mode === 'host-mirror' && result.data.stdout.includes('real-host-history') && result.data.stdout.includes('+staged') && result.data.stdout.includes('+unstaged'))
|
||||
check('queries do not consume a host write approval', approvals === 1 && result.data.git?.persistence === 'unchanged')
|
||||
result = await run('echo complete-local>tracked.txt && git add tracked.txt && git commit -q -m complete-local-commit && git branch feature && git tag v1')
|
||||
check('private commits, branches and tags retain host ancestry without touching the host', result.ok && !hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('diff').includes('+unstaged'))
|
||||
const published = await gitHostAction('commit', context)
|
||||
check('one approved group publishes worktree and actual host Git metadata', published.writeback === 'committed' && !!published.groupId && approvals === 2 && hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('branch', '--list', 'feature').includes('feature') && hostGit('tag', '--list', 'v1').includes('v1'))
|
||||
check('publication leaves the real host index and worktree coherent', hostGit('status', '--porcelain=v1').trim() === '' && (await fs.readFile(join(workspace, 'tracked.txt'), 'utf8')).includes('complete-local'))
|
||||
check('host executable configuration survives publication without being imported', hostGit('config', '--get', 'credential.helper').includes('SHOULD_NOT_RUN'))
|
||||
check('host status is bound to the imported session and clears metadata changes', (await hostGitStatus(workspace, context.sessionId)).pendingMetadata === false && (await hostGitStatus(workspace, 'another-session')).state === 'unimported')
|
||||
const literalMessage = 'literal %PATH% & ^ "quoted"'
|
||||
check('native argv executes Git directly without a command-shell expansion', (await gitRun(['commit', '--allow-empty', '-m', literalMessage])).ok && (await gitRun(['log', '-1', '--format=%s'])).content.includes(literalMessage))
|
||||
check('branch switching retains the real host branch topology', (await gitRun(['switch', 'feature'])).ok)
|
||||
await run('echo feature-change>feature.txt && git add feature.txt && git commit -q -m feature-change')
|
||||
await gitRun(['switch', 'main']); await gitRun(['merge', '--no-edit', 'feature'])
|
||||
check('a normal merge completes in the isolated draft', (await gitRun(['log', '--oneline', '-3'])).content.includes('feature-change'))
|
||||
await run('echo scratch>stash.txt && git add stash.txt')
|
||||
check('stash creation and restore persist across Jobs', (await gitRun(['stash', 'push', '-m', 'saved-stash'])).ok && (await gitRun(['stash', 'list'])).content.includes('saved-stash') && (await gitRun(['stash', 'pop'])).ok)
|
||||
await gitRun(['reset', '--hard', 'HEAD'])
|
||||
await gitRun(['switch', '-c', 'conflict-side'])
|
||||
await run('echo side-value>tracked.txt && git add tracked.txt && git commit -q -m side-conflict')
|
||||
await gitRun(['switch', 'main'])
|
||||
await run('echo main-value>tracked.txt && git add tracked.txt && git commit -q -m main-conflict')
|
||||
const merge = await gitRun(['merge', '--no-edit', 'conflict-side'])
|
||||
check('a failed merge retains genuine conflict files and MERGE_HEAD for a later command', !merge.ok && (merge.data as { gitConflictState?: boolean }).gitConflictState && (await gitRun(['status', '--porcelain'])).content.includes('UU tracked.txt'))
|
||||
await run('echo resolved-value>tracked.txt && git add tracked.txt')
|
||||
check('conflict resolution commits with the owned noninteractive editor', (await gitRun(['commit', '--no-edit'])).ok)
|
||||
await gitRun(['switch', '-c', 'cherry-source'])
|
||||
await run('echo cherry>cherry.txt && git add cherry.txt && git commit -q -m cherry-source')
|
||||
await gitRun(['switch', 'main'])
|
||||
check('cherry-pick applies an existing real history commit', (await gitRun(['cherry-pick', 'cherry-source'])).ok && (await gitRun(['show', 'HEAD:cherry.txt'])).content.includes('cherry'))
|
||||
check('revert makes a new inverse commit', (await gitRun(['revert', '--no-edit', 'HEAD'])).ok)
|
||||
await gitRun(['switch', '-c', 'rebase-side'])
|
||||
await run('echo rebase>rebase.txt && git add rebase.txt && git commit -q -m rebase-side')
|
||||
await gitRun(['switch', 'main'])
|
||||
await run('echo base>base.txt && git add base.txt && git commit -q -m rebase-base')
|
||||
await gitRun(['switch', 'rebase-side'])
|
||||
check('rebase rewrites a private branch onto the updated base', (await gitRun(['rebase', 'main'])).ok && (await gitRun(['log', '-3', '--oneline'])).content.includes('rebase-base'))
|
||||
check('detached HEAD checkout and return survive command boundaries', (await gitRun(['switch', '--detach', 'HEAD~1'])).ok && (await gitRun(['status', '--short', '--branch'])).content.includes('HEAD') && (await gitRun(['switch', 'rebase-side'])).ok)
|
||||
check('worktree reset retains coherent object history', (await gitRun(['reset', '--hard', 'HEAD~1'])).ok && (await gitRun(['fsck', '--connectivity-only'])).ok)
|
||||
const beforeReject = hostGit('rev-parse', 'HEAD').trim()
|
||||
const rejected = await gitHostAction('commit', { ...context, approve: async () => false })
|
||||
check('rejecting the combined commit preserves host HEAD and private changes', rejected.writeback === 'rejected' && hostGit('rev-parse', 'HEAD').trim() === beforeReject && (await hostGitStatus(workspace, context.sessionId)).pendingMetadata)
|
||||
const integrated = await workspaceAction('commit', context)
|
||||
check('the common workspace commit routes imported Git through the same combined host transaction', integrated.writeback === 'committed' && !!integrated.git?.groupId && hostGit('branch', '--show-current').trim() === 'rebase-side')
|
||||
const newProject = join(fixture, 'new-project'), newContext = { ...context, workspaceRoot: newProject, sessionId: 'new-session' }
|
||||
await fs.mkdir(newProject); await fs.writeFile(join(newProject, 'new.txt'), 'new-host-file\n')
|
||||
const initialized = await gitHostAction('init', newContext)
|
||||
check('init creates an unborn private repository without a synthetic baseline or host .git', initialized.mode === 'new-repository' && !await fs.lstat(join(newProject, '.git')).catch(() => null))
|
||||
const firstCommit = await executeTool('sandbox_git', { action: 'run', argv: ['add', '.'] }, newContext)
|
||||
const firstHistory = await executeTool('sandbox_git', { action: 'run', argv: ['commit', '-m', 'first-real-commit'] }, newContext)
|
||||
check('a new private repository stages existing project files and makes its first commit', firstCommit.ok && firstHistory.ok)
|
||||
const firstPublish = await gitHostAction('commit', newContext)
|
||||
check('first host publication creates and identity-binds .git before publishing the real first commit', firstPublish.writeback === 'committed' && !!firstPublish.groupId && (await fs.lstat(join(newProject, '.git/HEAD'))).isFile())
|
||||
const linked = join(fixture, 'linked-worktree')
|
||||
hostGit('worktree', 'add', '-q', '-b', 'linked-test', linked)
|
||||
const linkedContext = { ...context, workspaceRoot: linked, sessionId: 'linked-session' }
|
||||
check('linked worktree import binds both private and common Git directory identities', (await gitHostAction('import', linkedContext)).linkedWorktree === true)
|
||||
await executeSandboxCommand('echo linked>linked.txt && git add linked.txt && git commit -q -m linked-commit', { ...linkedContext, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
const linkedPublish = await gitHostAction('commit', linkedContext)
|
||||
check('linked worktree publication commits its index/HEAD and shared objects/refs with separate native roots', linkedPublish.writeback === 'committed' && hostGit('log', '-1', '--oneline', 'linked-test').includes('linked-commit') && (await fs.readFile(join(linked, 'linked.txt'), 'utf8')).includes('linked'))
|
||||
console.log(`SANDBOX_GIT_COMPLETE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_COMPLETE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { resetSandboxGit, sandboxGitState } from '../src/main/sandbox/git-repository'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Git regression requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-'))
|
||||
const workspace = join(fixture, 'workspace space \u4e2d\u6587'), stateRoot = join(fixture, 'state'), home = join(fixture, 'host-home')
|
||||
await fs.mkdir(workspace); await fs.mkdir(home)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64'), useBundledGit: false })
|
||||
const source = process.env.YCODE_TEST_GIT_SOURCE ?? 'C:\\Program Files\\Git\\cmd\\git.exe'
|
||||
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)))
|
||||
Object.assign(environment, { HOME: home, USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' })
|
||||
const hostGit = (...args: string[]): string => execFileSync(source, args, { cwd: workspace, env: environment, windowsHide: true, encoding: 'utf8', timeout: 15_000 })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'git-session', runId: 'git-run', toolCallId: 'git-call', runtimes: ['git'], approve: async () => { approvals++; return true } }
|
||||
async function run(command: string, overrides: Partial<typeof context> = {}) {
|
||||
const result = await executeSandboxCommand(command, { ...context, ...overrides })
|
||||
await fs.writeFile(join(fixture, 'last-result.json'), JSON.stringify(result, null, 2) + '\n')
|
||||
return result
|
||||
}
|
||||
async function inventory(root: string): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {}
|
||||
async function walk(path: string, prefix = ''): Promise<void> {
|
||||
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
|
||||
const name = `${prefix}${entry.name}`
|
||||
if (entry.isDirectory()) await walk(join(path, entry.name), `${name}/`)
|
||||
else result[name] = createHash('sha256').update(await fs.readFile(join(path, entry.name))).digest('hex')
|
||||
}
|
||||
}
|
||||
await walk(root); return result
|
||||
}
|
||||
try {
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'host committed\n')
|
||||
await fs.writeFile(join(workspace, '.env'), 'DUMMY_HISTORICAL_SECRET_NOT_FOR_SANDBOX')
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=host-branch')
|
||||
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'add', '--all')
|
||||
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-q', '-m', 'HOST_HISTORY_MUST_NOT_BE_IMPORTED')
|
||||
await fs.unlink(join(workspace, '.env'))
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'current filtered snapshot\n')
|
||||
hostGit('add', 'tracked.txt')
|
||||
hostGit('config', 'credential.helper', '!echo HOST_CREDENTIAL_HELPER_MUST_NOT_RUN')
|
||||
hostGit('config', 'core.fsmonitor', '!echo HOST_FSMONITOR_MUST_NOT_RUN')
|
||||
const originalGit = await inventory(join(workspace, '.git'))
|
||||
await fs.mkdir(join(workspace, 'nested space'))
|
||||
await fs.writeFile(join(workspace, 'nested space/\u8def\u5f84.txt'), 'unicode baseline\n')
|
||||
await fs.writeFile(join(workspace, 'binary.dat'), Buffer.from([0, 1, 255, 0, 32]))
|
||||
await fs.writeFile(join(workspace, 'hook-script'), '#!/bin/sh\nexit 73\n')
|
||||
await fs.writeFile(join(workspace, '.gitattributes'), 'crlf.txt text eol=lf\nencoded.txt working-tree-encoding=UTF-16LE filter=host-filter\n')
|
||||
await fs.writeFile(join(workspace, 'crlf.txt'), 'raw CRLF baseline\r\n')
|
||||
await fs.writeFile(join(workspace, 'encoded.txt'), 'raw snapshot bytes\n')
|
||||
check('Git starts missing without inheriting the host installation', (await listSandboxToolchains()).find(item => item.kind === 'git')?.state === 'missing')
|
||||
await assert.rejects(run('git --version'), /尚未注册/)
|
||||
check('missing Git runtime fails before native launch', true)
|
||||
const runtime = await importSandboxToolchain('git', source)
|
||||
check('explicit Git import selects the x64 image and its DLL closure', runtime.state === 'registered' && runtime.features?.includes('git-local') && runtime.files! >= 7)
|
||||
const isolatedImage = join(fixture, 'missing-dll/git.exe')
|
||||
await fs.mkdir(join(fixture, 'missing-dll'))
|
||||
await fs.copyFile(join(stateRoot, 'toolchains/objects', runtime.id!, 'files/git.exe'), isolatedImage)
|
||||
await assert.rejects(importSandboxToolchain('git', isolatedImage), /ENOENT/)
|
||||
check('an incomplete import preserves the previously registered Git runtime', (await listSandboxToolchains()).find(item => item.kind === 'git')?.id === runtime.id)
|
||||
check('registered Git capability states the private repository boundary', (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'implemented' && item.detail.includes('沙盒专用')))
|
||||
let result = await run('git --version && git status --porcelain=v1 && git diff --exit-code && git diff --cached --exit-code && git branch --show-current && git log -1 --oneline')
|
||||
check('real isolated Git runs with a synthetic filtered baseline', result.ok && result.data.stdout.includes('git version') && result.data.stdout.includes('ycode-sandbox') && result.data.stdout.includes('filtered workspace snapshot') && !result.data.stdout.includes('HOST_HISTORY'))
|
||||
check('read-only Git does not ask for approval or change the repository revision', approvals === 0 && result.data.git?.initialized === true && result.data.git.persistence === 'unchanged')
|
||||
check('result and content explicitly distinguish sandbox state from host Git state', result.data.git?.mode === 'private-snapshot' && result.content.includes('不含宿主历史/索引') && result.data.toolchains?.[0].id === runtime.id)
|
||||
const baseline = await sandboxGitState(workspace)
|
||||
result = await run('git show "HEAD:nested space/\u8def\u5f84.txt" && git fsck --no-reflogs')
|
||||
check('synthetic trees/index support nested Unicode names, spaces and binary objects', result.ok && result.data.stdout.includes('unicode baseline'))
|
||||
result = await run('git diff --exit-code -- crlf.txt encoded.txt && git show HEAD:crlf.txt && git show HEAD:encoded.txt')
|
||||
check('raw-byte baseline remains clean with CRLF and project encoding/filter attributes', result.ok && result.data.stdout.includes('raw CRLF baseline') && result.data.stdout.includes('raw snapshot bytes') && result.data.git?.persistence === 'unchanged')
|
||||
result = await run('git status --porcelain=v1 && git log --all -p && git config --get-regexp "credential|fsmonitor|hooksPath"')
|
||||
check('host history, deleted credentials and host configuration are absent', result.ok && !result.data.stdout.includes('DUMMY_HISTORICAL_SECRET') && !result.data.stdout.includes('HOST_CREDENTIAL') && !result.data.stdout.includes('HOST_FSMONITOR'))
|
||||
check('repeated status/log preserves the exact private revision without approvals', (await sandboxGitState(workspace)).id === baseline.id && approvals === 0)
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'edited outside after sandbox baseline\n')
|
||||
result = await run('git status --short && git diff -- tracked.txt')
|
||||
check('status and diff observe external worktree edits against the private baseline', result.ok && result.data.stdout.includes(' M tracked.txt') && result.data.stdout.includes('+edited outside after sandbox baseline') && approvals === 0)
|
||||
result = await run('git add tracked.txt && git diff --cached --stat')
|
||||
check('staging is approval-bound and persists independently of worktree writeback', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'unchanged' && approvals === 1)
|
||||
result = await run('git diff --cached -- tracked.txt && git commit -q -m local-sandbox-commit')
|
||||
check('a later command sees the staged index and creates a local commit', result.ok && result.data.stdout.includes('+edited outside after sandbox baseline') && result.data.git?.persistence === 'saved' && approvals === 2)
|
||||
check('completed Git persistence can be queried from the durable run outcome', (await sandboxOperation(result.data.runId)).git?.revision === result.data.git?.revision)
|
||||
result = await run('git log -2 --oneline && git status --porcelain=v1 && git fsck --no-reflogs')
|
||||
check('private commits and valid objects survive command boundaries', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.stdout.includes('filtered workspace snapshot') && result.data.git?.persistence === 'unchanged')
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
result = await run('node --require "%YCODE_SANDBOX_PROCESS%" -e "console.log(require(\'child_process\').execFileSync(\'git\',[\'log\',\'-1\',\'--oneline\'],{encoding:\'utf8\'}))"', { runtimes: ['node', 'git'] })
|
||||
check('a controlled Node subprocess inherits the private Git runtime and repository', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.git?.persistence === 'unchanged')
|
||||
const beforeReject = await sandboxGitState(workspace)
|
||||
result = await run('git branch rejected-branch', { approve: async () => false })
|
||||
check('rejected metadata-only approval leaves the active revision unchanged', !result.ok && result.data.git?.persistence === 'rejected' && (await sandboxGitState(workspace)).id === beforeReject.id)
|
||||
result = await run('git branch --list rejected-branch')
|
||||
check('a rejected branch does not appear in later commands', result.ok && !result.data.stdout.trim())
|
||||
const beforeFailure = await sandboxGitState(workspace)
|
||||
result = await run('git branch failed-command-branch && exit /b 7')
|
||||
check('failed commands discard private metadata changes', !result.ok && result.data.exitCode === 7 && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
result = await executeSandboxCommand('git branch timed-out-branch && echo GIT_MUTATION_BEFORE_TIMEOUT && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, timeoutMs: 1200 })
|
||||
check('a real native timeout discards already-created private branch metadata', !result.ok && result.data.status === 'timeout' && result.data.stdout.includes('GIT_MUTATION_BEFORE_TIMEOUT') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
const controller = new AbortController(), cancellation = setTimeout(() => controller.abort(), 2500)
|
||||
try { result = await executeSandboxCommand('git branch cancelled-branch && echo GIT_MUTATION_BEFORE_CANCEL && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, signal: controller.signal }) }
|
||||
finally { clearTimeout(cancellation) }
|
||||
check('cancellation discards private mutations and releases the repository lock', !result.ok && result.data.status === 'cancelled' && result.data.stdout.includes('GIT_MUTATION_BEFORE_CANCEL') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
result = await run('git switch -c feature-local && echo sandbox-file>created.txt && git add created.txt && git commit -q -m approved-file-and-commit')
|
||||
check('approved worktree changes and private branch/commit are both saved', result.ok && result.data.writeback === 'committed' && result.data.git?.persistence === 'saved' && (await fs.readFile(join(workspace, 'created.txt'), 'utf8')).trim() === 'sandbox-file')
|
||||
check('file writeback retains an independently recoverable transaction', !!result.data.transactionId && !!result.data.authorizationId)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
result = await run('git status --short && git branch --show-current')
|
||||
check('file recovery becomes a visible worktree deletion without rewriting private history', result.ok && result.data.stdout.includes(' D created.txt') && result.data.stdout.includes('feature-local'))
|
||||
const beforeConflict = await sandboxGitState(workspace)
|
||||
await assert.rejects(run('git branch concurrent-conflict', { approve: async () => { await fs.appendFile(join(workspace, 'tracked.txt'), 'parallel edit\n'); return true } }), /Git.*期间变化/)
|
||||
check('an edit during metadata approval rejects stale private state', (await sandboxGitState(workspace)).id === beforeConflict.id)
|
||||
result = await run('mkdir "%GIT_DIR%\\hooks" && copy /y hook-script "%GIT_DIR%\\hooks\\pre-commit" >nul && git config core.hooksPath "%GIT_DIR%\\hooks" && git config core.fsmonitor "echo BAD_FSMONITOR" && git config credential.helper "echo BAD_CREDENTIAL" && git commit --allow-empty -q -m hooks-disabled')
|
||||
check('default hooks and fsmonitor remain disabled even after private repo config changes', result.ok && result.data.git?.persistence === 'saved')
|
||||
result = await run('git config --get core.fsmonitor && git config --get core.hooksPath && git ls-remote https://example.invalid/test.git')
|
||||
check('remote transport is denied without prompting for host credentials', !result.ok && /not allowed|not a git command|transport/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_CREDENTIAL'))
|
||||
const outside = join(fixture, 'outside.txt')
|
||||
await fs.writeFile(outside, 'HOST_OUTSIDE_MUST_NOT_BE_READ')
|
||||
result = await run(`git hash-object "${outside}"`)
|
||||
check('native Git cannot read an ordinary file outside its snapshot', !result.ok && /denied|not permitted/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_OUTSIDE'))
|
||||
result = await run('powershell.exe -NoProfile -NonInteractive -Command "try {[IO.File]::WriteAllText((Join-Path $env:YCODE_SANDBOX_ROOT \'tools/git/git.exe\'),\'bad\'); exit 9} catch {[Console]::WriteLine(\'GIT_RUNTIME_READONLY\')}"')
|
||||
check('AppContainer ACLs deny modification of the staged Git image', result.ok && result.data.stdout.includes('GIT_RUNTIME_READONLY'))
|
||||
const otherWorkspace = join(fixture, 'other-workspace'); await fs.mkdir(otherWorkspace)
|
||||
await fs.writeFile(join(otherWorkspace, '.git'), 'gitdir: C:/host-repository-must-not-be-opened\n')
|
||||
result = await run('git log --all --oneline', { workspaceRoot: otherWorkspace })
|
||||
check('another workspace receives a separate baseline and ignores host worktree gitfiles', result.ok && result.data.git?.initialized && !result.data.stdout.includes('local-sandbox-commit') && !result.data.stdout.includes('hooks-disabled'))
|
||||
await assert.rejects(importSandboxDependencies(workspace, 'git'), /node\/python/)
|
||||
check('Git runtime is not accepted as a Node/Python dependency-cache kind', true)
|
||||
const workspaceKey = createHash('sha256').update((await fs.realpath(workspace)).toLowerCase()).digest('hex')
|
||||
const privateRoot = join(stateRoot, 'git', workspaceKey)
|
||||
const active = JSON.parse(await fs.readFile(join(privateRoot, 'active.json'), 'utf8'))
|
||||
const privateFile = join(privateRoot, 'objects', active.id, 'files/HEAD'), privateBytes = await fs.readFile(privateFile)
|
||||
await fs.appendFile(privateFile, 'tampered')
|
||||
await assert.rejects(run('git status'), /metadata digest mismatch/)
|
||||
check('tampered saved metadata fails closed before execution', true)
|
||||
await fs.writeFile(privateFile, privateBytes)
|
||||
const hardlinkTarget = join(fixture, 'git-hardlink.txt'); await fs.writeFile(hardlinkTarget, privateBytes)
|
||||
await fs.unlink(privateFile); await fs.link(hardlinkTarget, privateFile)
|
||||
await assert.rejects(run('git status'), /metadata contains a link/)
|
||||
check('saved metadata hardlinks are rejected even when content digests match', true)
|
||||
await fs.unlink(privateFile); await fs.writeFile(privateFile, privateBytes)
|
||||
const runtimeIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
|
||||
const runtimeFile = join(stateRoot, 'toolchains/objects', runtimeIdentity.id, 'files/git.exe'), runtimeBytes = await fs.readFile(runtimeFile)
|
||||
await fs.appendFile(runtimeFile, 'tampered')
|
||||
await assert.rejects(run('git status'), /摘要不匹配/)
|
||||
check('tampered Git runtime fails before any metadata or worktree access', true)
|
||||
await fs.writeFile(runtimeFile, runtimeBytes)
|
||||
await assert.rejects(run('if not exist "%GIT_DIR%\\objects\\info" mkdir "%GIT_DIR%\\objects\\info"\r\necho C:/outside>"%GIT_DIR%\\objects\\info\\alternates"'), /unsafe sandbox Git metadata path/)
|
||||
check('external object alternates cannot be persisted', true)
|
||||
await assert.rejects(run('echo stale>"%GIT_DIR%\\index.lock"'), /unsafe sandbox Git metadata path/)
|
||||
check('incomplete Git lock files are rejected instead of poisoning the next command', true)
|
||||
const policy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['tracked.txt'] }, workspaceRoot: workspace })
|
||||
await assert.rejects(run('git show HEAD:tracked.txt'), /policy changed/)
|
||||
check('newly denied paths cannot leak through an older private history', true)
|
||||
await resetSandboxGit(workspace)
|
||||
result = await run('git ls-files && git log -1 --oneline')
|
||||
check('explicit reset creates a new filtered baseline after policy changes', result.ok && result.data.git?.initialized && !result.data.stdout.includes('tracked.txt') && result.data.stdout.includes('filtered workspace snapshot'))
|
||||
check('reset archives earlier revisions without mutating host Git', (await fs.readdir(privateRoot)).some(name => name.startsWith('archived-')) && JSON.stringify(await inventory(join(workspace, '.git'))) === JSON.stringify(originalGit))
|
||||
check('Git metadata remains outside the host worktree and policy retains network deny', !(await fs.stat(join(workspace, 'repository')).catch(() => null)) && (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('tracked.txt'))
|
||||
const denyPolicy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: denyPolicy.revision, rules: { writeback: 'deny' }, workspaceRoot: otherWorkspace })
|
||||
await resetSandboxGit(otherWorkspace)
|
||||
result = await run('git status --short', { workspaceRoot: otherWorkspace })
|
||||
const denyBaseline = await sandboxGitState(otherWorkspace)
|
||||
await assert.rejects(run('git branch forbidden-by-policy', { workspaceRoot: otherWorkspace }), /禁止写回/)
|
||||
check('writeback-deny policy also refuses private repository publication', result.ok && (await sandboxGitState(otherWorkspace)).id === denyBaseline.id)
|
||||
console.log(`SANDBOX_GIT_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, scope: 'Real AppContainer Git for Windows and private filtered repository. No host Git history/index writeback or remote transport is supported.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createServer, type ServerResponse, type IncomingMessage } from 'node:http'
|
||||
import { createServer as createTlsServer } from 'node:https'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { gitHostAction } from '../src/main/sandbox/git-host-service'
|
||||
import { gitRemoteAction, gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
|
||||
import { importGitCredential, listGitCredentials, removeGitCredential, gitRemoteUrl } from '../src/main/sandbox/git-credentials'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { gitRemoteEndpoint } from '../src/main/sandbox/git-credentials'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { Server: SshServer } = require(resolve('scripts/fixtures/git-transport/node_modules/ssh2'))
|
||||
const selfsigned = require(resolve('scripts/fixtures/git-transport/node_modules/selfsigned'))
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-remote-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state'), remote = join(fixture, 'remote.git')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
|
||||
const environment = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
|
||||
const prefix = ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid']
|
||||
const hostGit = (...args: string[]): string => execFileSync(git, [...prefix, ...args], { cwd: workspace, env: environment, encoding: 'utf8', windowsHide: true })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let approvals = 0, httpRequests = 0, sshConnections = 0, requireAuth = false, disconnectPush = false, holdResponses = false
|
||||
let onHeldRequest: (() => void) | undefined
|
||||
const heldResponses = new Set<ServerResponse>(), remotePaths = new Map([['/repo.git', remote]])
|
||||
const context = { workspaceRoot: workspace, sessionId: 'remote-session', runId: 'remote-run', toolCallId: 'remote-call', approve: async () => { approvals++; return true } }
|
||||
const run = (argv: string[]) => executeTool('sandbox_git', { action: 'run', argv }, context)
|
||||
const children = new Set<ReturnType<typeof spawn>>()
|
||||
function serve(request: IncomingMessage, response: ServerResponse): void {
|
||||
httpRequests++
|
||||
if (holdResponses) { heldResponses.add(response); response.once('close', () => heldResponses.delete(response)); onHeldRequest?.(); return }
|
||||
if (requireAuth && request.headers.authorization !== `Basic ${Buffer.from('fixture:DUMMY_GIT_TOKEN_97832').toString('base64')}`) { response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }); response.end(); return }
|
||||
const url = new URL(request.url!, 'http://fixture.invalid'), service = request.method === 'GET' ? url.searchParams.get('service') : url.pathname.split('/').at(-1)
|
||||
const repository = remotePaths.get('/' + url.pathname.split('/')[1])
|
||||
if (!repository || !['git-upload-pack', 'git-receive-pack'].includes(service ?? '')) { response.writeHead(404); response.end(); return }
|
||||
const advertise = request.method === 'GET', args = [...prefix, service!.slice(4), '--stateless-rpc', ...(advertise ? ['--advertise-refs'] : []), repository]
|
||||
const child = spawn(git, args, { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); children.add(child)
|
||||
child.stderr.on('data', () => undefined); child.on('error', error => response.destroy(error)); child.on('close', () => children.delete(child))
|
||||
response.writeHead(200, { 'Content-Type': `application/x-${service}-${advertise ? 'advertisement' : 'result'}`, 'Cache-Control': 'no-cache' })
|
||||
if (advertise) { const header = Buffer.from(`# service=${service}\n`); response.write(Buffer.from((header.length + 4).toString(16).padStart(4, '0'))); response.write(header); response.write('0000'); child.stdin.end() }
|
||||
else request.pipe(child.stdin)
|
||||
if (disconnectPush && service === 'git-receive-pack' && !advertise) { child.stdout.resume(); child.once('close', () => response.destroy()) }
|
||||
else child.stdout.pipe(response)
|
||||
child.stdin.on('error', () => undefined)
|
||||
}
|
||||
const http = createServer(serve), pems = selfsigned.generate([{ name: 'commonName', value: '127.0.0.1' }], { days: 2, keySize: 2048, algorithm: 'sha256', extensions: [
|
||||
{ name: 'basicConstraints', cA: true }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
|
||||
{ name: 'extKeyUsage', serverAuth: true }, { name: 'subjectAltName', altNames: [{ type: 7, ip: '127.0.0.1' }] }
|
||||
] })
|
||||
const https = createTlsServer({ key: pems.private, cert: pems.cert }, serve)
|
||||
const listen = (server: any): Promise<number> => new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => done(server.address().port)) })
|
||||
function sshPublic(jwk: JsonWebKey): Buffer {
|
||||
const field = (data: Buffer): Buffer => { const header = Buffer.alloc(4); header.writeUInt32BE(data.length); return Buffer.concat([header, data]) }
|
||||
const integer = (text: string): Buffer => { const data = Buffer.from(text, 'base64url'); return field(data[0] & 128 ? Buffer.concat([Buffer.from([0]), data]) : data) }
|
||||
return Buffer.concat([field(Buffer.from('ssh-rsa')), integer(jwk.e!), integer(jwk.n!)])
|
||||
}
|
||||
const hostKey = generateKeyPairSync('rsa', { modulusLength: 2048 }), clientKey = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
const hostPem = hostKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString(), clientPem = clientKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString()
|
||||
const clientPublic = sshPublic(clientKey.publicKey.export({ format: 'jwk' })), clients = new Set<any>()
|
||||
const ssh = new SshServer({ hostKeys: [hostPem] }, (client: any) => {
|
||||
sshConnections++; clients.add(client); client.on('error', () => undefined); client.on('close', () => clients.delete(client))
|
||||
client.on('authentication', (auth: any) => { if (auth.method === 'publickey' && auth.key.data.equals(clientPublic)) auth.accept(); else auth.reject() })
|
||||
client.on('ready', () => client.on('session', (accept: any) => accept().on('exec', (accept: any, reject: any, info: any) => {
|
||||
const match = /^git-(upload-pack|receive-pack) '\/repo\.git'$/.exec(info.command)
|
||||
if (!match) { reject(); return }
|
||||
const stream = accept(), child = spawn(git, [...prefix, match[1], remote], { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
children.add(child); stream.pipe(child.stdin); child.stdout.pipe(stream, { end: false }); child.stderr.pipe(stream.stderr, { end: false }); child.stdin.on('error', () => undefined)
|
||||
child.once('close', code => { children.delete(child); stream.exit(code ?? 1); stream.end() })
|
||||
})))
|
||||
})
|
||||
try {
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main'); await fs.writeFile(join(workspace, 'file.txt'), 'remote-base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'remote-base')
|
||||
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', remote)
|
||||
hostGit('-c', 'protocol.file.allow=always', 'push', '-q', remote, 'main')
|
||||
await gitHostAction('import', context)
|
||||
const httpPort = await listen(http), tlsPort = await listen(https), sshPort = await listen(ssh)
|
||||
const httpUrl = `http://127.0.0.1:${httpPort}/repo.git`, httpsUrl = `https://127.0.0.1:${tlsPort}/repo.git`, sshUrl = `ssh://fixture@127.0.0.1:${sshPort}/repo.git`
|
||||
const beforeDeny = httpRequests
|
||||
let result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, approve: async () => false })
|
||||
check('remote rejection sends no network requests', result.state === 'denied' && httpRequests === beforeDeny)
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, context)
|
||||
check('approved smart HTTP discovery uses the bundled transport helper', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
|
||||
result = await gitRemoteAction({ action: 'fetch', url: httpUrl, branch: 'main' }, context)
|
||||
check('approved fetch persists remote tracking refs without host metadata writes', result.state === 'completed' && !!result.revision && !hostGit('branch', '-r').includes('origin/main'))
|
||||
check('isolated commands observe the fetched tracking refs', (await run(['log', '-1', '--oneline', 'refs/remotes/origin/main'])).content.includes('remote-base'))
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl }, context)
|
||||
check('HTTPS refuses an untrusted self-signed server without an explicit CA', result.state === 'failed' && /certificate|SSL|schannel/i.test(result.reason ?? ''))
|
||||
const credential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_GIT_TOKEN_97832', caCertificate: pems.cert })
|
||||
requireAuth = true
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: credential.id }, context)
|
||||
check('HTTPS authenticates using an exact-remote DPAPI credential and verifies the explicit CA', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
|
||||
const stored = await fs.readFile(join(stateRoot, 'git-credentials', `${credential.id}.json`), 'utf8')
|
||||
check('credential records and result queries contain no plaintext token', !stored.includes('DUMMY_GIT_TOKEN_97832') && !JSON.stringify(await gitRemoteOperation(context, result.operationId)).includes('DUMMY_GIT_TOKEN_97832'))
|
||||
check('credential listing exposes metadata only', (await listGitCredentials()).some(value => value.id === credential.id) && !JSON.stringify(await listGitCredentials()).includes('DUMMY_GIT_TOKEN_97832'))
|
||||
const beforeScope = httpRequests
|
||||
await assert.rejects(gitRemoteAction({ action: 'ls-remote', url: httpsUrl.replace('repo.git', 'other.git'), credentialId: credential.id }, context), /scoped/)
|
||||
check('credential scope mismatch is rejected before any connection', httpRequests === beforeScope)
|
||||
const revokedCredential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_REVOKED_TOKEN', caCertificate: pems.cert })
|
||||
const beforeRevoke = httpRequests
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: revokedCredential.id }, { ...context, approve: async () => { await removeGitCredential(revokedCredential.id); return true } })
|
||||
check('revoking a credential during approval prevents the first connection', result.state === 'failed' && httpRequests === beforeRevoke)
|
||||
requireAuth = false
|
||||
await run(['commit', '--allow-empty', '-m', 'remote-push-commit'])
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'] }, context)
|
||||
check('approved atomic push publishes exact intended refs', result.state === 'completed' && result.writeback === 'remote-committed' && hostGit('--git-dir=' + remote, 'log', '-1', '--oneline', 'feature').includes('remote-push-commit'))
|
||||
check('remote result survives a fresh owned query', (await gitRemoteOperation(context, result.operationId)).writeback === 'remote-committed')
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/dry-run'], dryRun: true }, context)
|
||||
check('push dry-run performs negotiation without publishing a ref', result.state === 'completed' && result.writeback === 'not-applied' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/dry-run').trim())
|
||||
const remoteTree = hostGit('--git-dir=' + remote, 'rev-parse', 'main^{tree}').trim()
|
||||
const divergent = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-m', 'divergent-remote').trim()
|
||||
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/feature', divergent)
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
|
||||
check('force-with-lease permits a non-fast-forward update only with the approved old object', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() !== divergent)
|
||||
const protectedFeature = hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim()
|
||||
await run(['commit', '--allow-empty', '-m', 'stale-lease-attempt'])
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
|
||||
check('a stale force-with-lease is refused without changing the remote ref', result.state !== 'completed' && /stale|rejected/i.test(result.reason ?? '') && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() === protectedFeature)
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: [':refs/heads/feature'] }, context)
|
||||
check('explicit approved deletion removes only the named remote ref', result.state === 'completed' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/feature').trim() && !!hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/main').trim())
|
||||
await run(['remote', 'add', 'origin', httpUrl]); await run(['config', 'branch.main.remote', 'origin']); await run(['config', 'branch.main.merge', 'refs/heads/main'])
|
||||
await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/main'] }, context)
|
||||
const advanceRemote = (message: string): string => {
|
||||
const parent = hostGit('--git-dir=' + remote, 'rev-parse', 'main').trim(), next = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-p', parent, '-m', message).trim()
|
||||
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/main', next); return next
|
||||
}
|
||||
const fastForward = advanceRemote('remote-fast-forward')
|
||||
let pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
|
||||
check('pull derives the current upstream and fast-forwards only the private draft by default', pulled.ok && (await run(['rev-parse', 'HEAD'])).content.includes(fastForward) && !hostGit('log', '-1', '--oneline').includes('remote-fast-forward'))
|
||||
await run(['commit', '--allow-empty', '-m', 'local-divergence'])
|
||||
const privateHead = (await run(['rev-parse', 'HEAD'])).content
|
||||
advanceRemote('remote-divergence')
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
|
||||
check('default pull refuses divergent history and preserves the local commit', !pulled.ok && (await run(['rev-parse', 'HEAD'])).content === privateHead)
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'rebase' }, context)
|
||||
check('explicit pull rebase integrates divergence inside the isolated draft', pulled.ok && (await run(['log', '-3', '--oneline'])).content.includes('remote-divergence'))
|
||||
await run(['commit', '--allow-empty', '-m', 'local-merge-divergence']); advanceRemote('remote-merge-divergence')
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'merge' }, context)
|
||||
check('explicit pull merge retains both histories with a real merge commit', pulled.ok && /[a-f0-9]{40} [a-f0-9]{40}/.test((await run(['log', '-1', '--format=%P'])).content))
|
||||
const sshCredential = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${sshPublic(hostKey.publicKey.export({ format: 'jwk' })).toString('base64')}\n` })
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: sshCredential.id }, context)
|
||||
check('bundled SSH authenticates with an explicit key and pinned host key', result.state === 'completed' && result.stdout!.includes('refs/heads/main') && sshConnections > 0)
|
||||
result = await gitRemoteAction({ action: 'fetch', url: sshUrl, credentialId: sshCredential.id, branch: 'main' }, context)
|
||||
check('SSH fetch persists objects through the same controlled mirror', result.state === 'completed' && !!result.revision)
|
||||
result = await gitRemoteAction({ action: 'push', url: sshUrl, credentialId: sshCredential.id, refspecs: ['HEAD:refs/heads/ssh-pushed'] }, context)
|
||||
check('SSH push updates only the approved reference', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'ssh-pushed').trim().length === 40)
|
||||
const badPin = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${clientPublic.toString('base64')}\n` })
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: badPin.id }, context)
|
||||
check('a mismatched SSH host key fails closed', result.state === 'failed' && /host key|identification/i.test(result.reason ?? ''))
|
||||
const cloneWorkspace = join(fixture, 'clone-project'), cloneContext = { ...context, workspaceRoot: cloneWorkspace, sessionId: 'clone-session' }
|
||||
await fs.mkdir(cloneWorkspace)
|
||||
const cloned = await executeTool('sandbox_git', { action: 'clone', url: httpUrl }, cloneContext)
|
||||
await fs.writeFile(join(fixture, 'clone-result.json'), JSON.stringify(cloned, null, 2))
|
||||
check('clone fetches and checks out the default branch inside an empty draft', cloned.ok && !await fs.lstat(join(cloneWorkspace, 'file.txt')).catch(() => null))
|
||||
check('cloned worktree bytes are readable through isolated Git commands', (await executeTool('sandbox_git', { action: 'run', argv: ['show', 'HEAD:file.txt'] }, cloneContext)).content.includes('remote-base'))
|
||||
check('ordinary clone fetches the other remote branches as well as the default branch', (await executeTool('sandbox_git', { action: 'run', argv: ['branch', '-r'] }, cloneContext)).content.includes('origin/ssh-pushed'))
|
||||
const clonePublished = await workspaceAction('commit', cloneContext)
|
||||
check('clone publication creates a real host repository with its original remote ancestry', clonePublished.writeback === 'committed' && !!clonePublished.git?.groupId && (await fs.readFile(join(cloneWorkspace, 'file.txt'), 'utf8')).includes('remote-base'))
|
||||
const maintenanceBundle = join(fixture, 'maintenance.mjs'), cliProject = join(fixture, 'cli-project'), cliRequest = join(fixture, 'cli-request.json')
|
||||
await require('esbuild').build({ entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: maintenanceBundle, logLevel: 'silent' })
|
||||
await fs.mkdir(cliProject)
|
||||
const cli = (command: string): Promise<any> => new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, [maintenanceBundle, command, '--workspace', cliProject, '--session', 'cli-session', '--state-root', stateRoot, '--request', cliRequest, '--approve'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = '', stderr = ''; const timer = setTimeout(() => child.kill(), 120000)
|
||||
child.stdout.on('data', data => { stdout += data }); child.stderr.on('data', data => { stderr += data })
|
||||
child.once('error', reject); child.once('close', code => { clearTimeout(timer); if (code) reject(new Error(`Git maintenance failed (${code}): ${stderr}`)); else { try { done(JSON.parse(stdout)) } catch (error) { reject(error) } } })
|
||||
})
|
||||
await fs.writeFile(cliRequest, JSON.stringify({ action: 'clone', url: httpUrl }))
|
||||
check('maintenance clone performs the same isolated checkout as the conversation tool', (await cli('git-remote')).ok)
|
||||
await fs.writeFile(cliRequest, JSON.stringify(['show', 'HEAD:file.txt']))
|
||||
const cliRead = await cli('git-run')
|
||||
check('a fresh maintenance process sees the cloned history and retained draft', cliRead.ok && cliRead.content.includes('remote-base'))
|
||||
const emptyRemote = join(fixture, 'empty.git'), emptyProject = join(fixture, 'empty-project')
|
||||
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', emptyRemote); remotePaths.set('/empty.git', emptyRemote); await fs.mkdir(emptyProject)
|
||||
const emptyClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/empty.git` }, { ...context, workspaceRoot: emptyProject, sessionId: 'empty-clone' })
|
||||
check('cloning an empty remote retains an unborn private repository', emptyClone.ok && !await fs.lstat(join(emptyProject, '.git')).catch(() => null))
|
||||
const shaRemote = join(fixture, 'sha256.git'), shaProject = join(fixture, 'sha256-project')
|
||||
hostGit('init', '--bare', '-q', '--object-format=sha256', '--template=', '--initial-branch=main', shaRemote)
|
||||
const shaTree = hostGit('--git-dir=' + shaRemote, 'hash-object', '-w', '-t', 'tree', '--stdin').trim(), shaCommit = hostGit('--git-dir=' + shaRemote, 'commit-tree', shaTree, '-m', 'sha256-history').trim()
|
||||
hostGit('--git-dir=' + shaRemote, 'update-ref', 'refs/heads/main', shaCommit); remotePaths.set('/sha256.git', shaRemote); await fs.mkdir(shaProject)
|
||||
const shaContext = { ...context, workspaceRoot: shaProject, sessionId: 'sha256-clone' }
|
||||
const shaClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/sha256.git` }, shaContext)
|
||||
check('clone detects and preserves a real SHA-256 repository', shaClone.ok && shaCommit.length === 64 && (await executeTool('sandbox_git', { action: 'run', argv: ['rev-parse', 'HEAD'] }, shaContext)).content.includes(shaCommit))
|
||||
await removeGitCredential(credential.id)
|
||||
check('credential removal revokes subsequent lookup', !(await listGitCredentials()).some(value => value.id === credential.id))
|
||||
for (const bad of ['ext::echo pwned', 'file:///C:/private.git', 'https://token@example.invalid/repo.git', 'http://example.invalid/repo.git', 'ssh://git@host/repo;bad']) assert.throws(() => gitRemoteUrl(bad))
|
||||
check('remote URL parsing rejects helper execution, inline secrets and unsafe protocols', true)
|
||||
const scp = 'fixture@example.invalid:relative/repo.git'
|
||||
check('SCP remotes retain relative transport paths while credentials use an unambiguous scope', gitRemoteUrl(scp).pathname === '/~/relative/repo.git' && gitRemoteEndpoint(scp, gitRemoteUrl(scp)) === scp)
|
||||
disconnectPush = true
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/lost-reply'] }, context)
|
||||
check('lost push acknowledgement remains unknown after real remote refs changed', result.state === 'unknown' && result.writeback === 'unknown' && hostGit('--git-dir=' + remote, 'rev-parse', 'lost-reply').trim().length === 40)
|
||||
check('durable query never infers lost push success or retries it', (await gitRemoteOperation(context, result.operationId)).state === 'unknown')
|
||||
disconnectPush = false
|
||||
const reconciled = await reconcileGitRemote(context, result.operationId)
|
||||
check('explicit reconciliation confirms current remote refs without repeating the push', reconciled.state === 'completed' && reconciled.reason!.includes('observing the current remote refs'))
|
||||
const heldOperation = async (cancel: () => Promise<void>, signal?: AbortSignal) => {
|
||||
holdResponses = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const requested = new Promise<void>((done, reject) => { onHeldRequest = done; timer = setTimeout(() => reject(new Error('Git did not reach the real transport cancellation boundary')), 15000) })
|
||||
const operation = gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, signal })
|
||||
try { await requested; await cancel(); return await operation }
|
||||
finally { clearTimeout(timer); holdResponses = false; onHeldRequest = undefined; for (const response of heldResponses) response.destroy() }
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const cancelled = await heldOperation(async () => controller.abort(), controller.signal)
|
||||
check('cancelling an active real Git transport terminates its Job and saves the failed read outcome', cancelled.state === 'failed' && /cancel/i.test(cancelled.reason ?? '') && (await gitRemoteOperation(context, cancelled.operationId)).state === 'failed')
|
||||
const revoked = await heldOperation(async () => { const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['revoked-by-test.txt'] } }) })
|
||||
check('policy revocation terminates an in-flight trusted Git transport', revoked.state === 'failed' && /policy|策略/i.test(revoked.reason ?? ''))
|
||||
console.log(`SANDBOX_GIT_REMOTE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
for (const child of children) child.kill()
|
||||
for (const client of clients) client.end()
|
||||
http.closeAllConnections(); https.closeAllConnections(); http.close(); https.close(); ssh.close()
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, approvals, httpRequests, sshConnections }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_REMOTE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { hostGitRoot, loadHostGitBinding } from '../src/main/sandbox/git-host-source'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { modeAllowsTool } from '../src/main/agent/mode-policy'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { cleanupGitControls, createGitControl } from '../src/main/sandbox/git-control'
|
||||
import { withSandboxExecution } from '../src/main/sandbox/execution'
|
||||
import { PersistentWorkspace, workspaceKey } from '../src/main/sandbox/workspace'
|
||||
|
||||
if (process.argv[2] === '--worker-commit') {
|
||||
configureSandbox({ stateRoot: process.argv[4], resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await gitHostAction('commit', { workspaceRoot: process.argv[3], sessionId: process.argv[5], approve: async () => true })
|
||||
process.exit(0)
|
||||
}
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-safety-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
|
||||
const env = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
|
||||
const hostGit = (...args: string[]): string => execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args], { cwd: workspace, env, encoding: 'utf8', windowsHide: true })
|
||||
const context = { workspaceRoot: workspace, sessionId: 'safety-session', runId: 'safety-run', toolCallId: 'safety-call', approve: async () => true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
async function savedJson(path: string): Promise<any> { return fs.readFile(path, 'utf8').then(JSON.parse, error => { if (error.code === 'ENOENT') return null; throw error }) }
|
||||
try {
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main')
|
||||
await fs.writeFile(join(workspace, 'file.txt'), 'base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'base')
|
||||
const rejected = await gitHostAction('import', { ...context, approve: async () => false })
|
||||
check('denied history import creates no active host binding', rejected.writeback === 'rejected' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
let prompt = ''
|
||||
await assert.rejects(gitHostAction('import', { ...context, approve: async title => { prompt = title; hostGit('update-ref', 'refs/heads/concurrent-import', 'HEAD'); return true } }), /changed while import/)
|
||||
check('import approval is invalidated by an external reference change', prompt.includes('历史可能包含') && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
await gitHostAction('import', context)
|
||||
const priorRevision = (await workspaceAction('status', context)).revision!
|
||||
let releaseQueue!: () => void, enteredQueue!: () => void
|
||||
const queueEntered = new Promise<void>(done => { enteredQueue = done }), queueRelease = new Promise<void>(done => { releaseQueue = done })
|
||||
const queuedMutation = withSandboxExecution(workspace, undefined, async () => {
|
||||
enteredQueue(); await queueRelease
|
||||
const policy = await effectiveSandboxPolicy(workspace), binding = await loadHostGitBinding(policy, context.sessionId)
|
||||
const draft = await PersistentWorkspace.open(policy, context.sessionId, binding!.layout.projectIdentity)
|
||||
try { await fs.writeFile(join(workspace, 'queued-external.txt'), 'queued-host-edit\n'); await draft.synchronize() } finally { await draft.close() }
|
||||
})
|
||||
await queueEntered
|
||||
const staleDiscard = workspaceAction('discard', context, priorRevision)
|
||||
const staleDiscardCheck = assert.rejects(staleDiscard, /版本已变化/)
|
||||
await new Promise(done => setTimeout(done, 100)); releaseQueue(); await queuedMutation; await staleDiscardCheck
|
||||
check('workspace actions recheck their expected revision after waiting in the shared Git queue', (await workspaceAction('status', context)).revision !== priorRevision)
|
||||
const nativeTool = await executeTool('sandbox_git', { action: 'run', argv: ['status', '--short'] }, { ...context, permissionMode: 'full-access' })
|
||||
check('explicit Git tool keeps native confinement in a full-access context', nativeTool.ok && (nativeTool.data as { isolated: boolean }).isolated)
|
||||
check('model tool rejects forged project/session ownership', !(await executeTool('sandbox_git', { action: 'status', workspaceRoot: fixture, sessionId: 'other' }, context)).ok)
|
||||
check('planning mode cannot mutate or import Git state', !modeAllowsTool('plan', 'sandbox_git') && !modeAllowsTool('ask', 'sandbox_git'))
|
||||
const defaultResult = await executeSandboxCommand('git log -1 --oneline', { ...context, runtimes: ['git'] })
|
||||
check('ordinary Git runtime calls select the imported session mirror after import', defaultResult.ok && defaultResult.data.git?.mode === 'host-mirror' && defaultResult.data.workspace?.mode === 'persistent')
|
||||
await run('echo pending>file.txt && git add file.txt && git commit -q -m pending')
|
||||
const original = hostGit('rev-parse', 'HEAD').trim()
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { hostGit('update-ref', 'refs/heads/raced', 'HEAD'); return true } }), /changed while commit approval/)
|
||||
check('host metadata edits during commit approval prevent every host file write', hostGit('rev-parse', 'HEAD').trim() === original && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n')
|
||||
const refreshed = await executeSandboxCommand('git show-ref raced && git log -1 --oneline', { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
check('independent external reference updates merge without losing private commits', refreshed.ok && refreshed.data.stdout.includes('raced') && refreshed.data.stdout.includes('pending'))
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.chmod(join(workspace, '.git/index'), 0o444); return true } }), /read.only|readonly|原生|Transaction/i)
|
||||
await fs.chmod(join(workspace, '.git/index'), 0o666)
|
||||
check('a metadata-phase failure compensates the already committed worktree phase', (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n' && hostGit('rev-parse', 'HEAD').trim() === original)
|
||||
const bindingRoot = hostGitRoot(workspace, context.sessionId)
|
||||
check('compensated failure clears only its owned Git coordination locks', !await fs.lstat(join(workspace, '.git/index.lock')).catch(() => null) && !await fs.lstat(join(bindingRoot, 'commit.json')).catch(() => null))
|
||||
const partial = await gitHostAction('commit', { ...context, approve: async () => {
|
||||
await fs.rename(join(bindingRoot, 'binding.json'), join(bindingRoot, 'binding-before-fault.json')); await fs.mkdir(join(bindingRoot, 'binding.json')); return true
|
||||
} })
|
||||
check('private metadata failure after real host writes retains a committed outcome', partial.writeback === 'committed' && partial.state === 'interrupted' && hostGit('log', '-1', '--oneline').includes('pending') && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
|
||||
const committedHead = hostGit('rev-parse', 'HEAD').trim(), beforeRecoveryTransactions = (await fs.readdir(join(stateRoot, 'transactions'))).length
|
||||
await fs.rmdir(join(bindingRoot, 'binding.json')); await fs.rename(join(bindingRoot, 'binding-before-fault.json'), join(bindingRoot, 'binding.json'))
|
||||
const recovered = await gitHostAction('recover', context)
|
||||
check('group recovery finishes bookkeeping without replaying committed host mutations', recovered.writeback === 'committed' && recovered.state === 'ready' && hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readdir(join(stateRoot, 'transactions'))).length === beforeRecoveryTransactions)
|
||||
await run('echo root-swap>file.txt && git add file.txt && git commit -q -m root-swap')
|
||||
const backup = join(workspace, '.git-before-swap')
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.rename(join(workspace, '.git'), backup); await fs.cp(backup, join(workspace, '.git'), { recursive: true }); return true } }), /changed/)
|
||||
check('same-content .git directory replacement invalidates the approved root identity', hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
|
||||
const replacement = resolve(workspace, '.git'), rel = relative(fixture, replacement)
|
||||
assert.ok(rel && !rel.startsWith('..') && !isAbsolute(rel)); await fs.rm(replacement, { recursive: true }); await fs.rename(backup, replacement)
|
||||
await gitHostAction('discard', context)
|
||||
await run('git commit --allow-empty -q -m cleanup-acknowledgement')
|
||||
const workspaceLock = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId), 'workspace.lock')
|
||||
const alteredLockNonce = randomUUID()
|
||||
const cleanupFailure = await gitHostAction('commit', { ...context, approve: async () => { await fs.writeFile(workspaceLock, JSON.stringify({ pid: process.pid, nonce: alteredLockNonce }) + '\n'); return true } })
|
||||
await fs.writeFile(join(fixture, 'cleanup-failure-result.json'), JSON.stringify(cleanupFailure, null, 2))
|
||||
check('a lock cleanup failure never replaces an already committed host outcome', cleanupFailure.writeback === 'committed' && cleanupFailure.state === 'interrupted' && /cleanup requires maintenance/.test(cleanupFailure.reason ?? '') && hostGit('log', '-1', '--oneline').includes('cleanup-acknowledgement'))
|
||||
assert.equal((await savedJson(workspaceLock)).nonce, alteredLockNonce); await fs.unlink(workspaceLock)
|
||||
for (let index = 0; index < 20; index++) await fs.writeFile(join(workspace, `large-${index}.bin`), randomBytes(512 * 1024))
|
||||
await run('echo crash-draft>file.txt && git add . && git commit -q -m crash-draft && git gc --quiet')
|
||||
const beforeCrashHead = hostGit('rev-parse', 'HEAD').trim(), beforeCrashBytes = await fs.readFile(join(workspace, 'file.txt'))
|
||||
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker-commit', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let workerError = ''; worker.stderr.on('data', (data: Buffer) => { workerError = (workerError + data.toString()).slice(-4000) })
|
||||
const closed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
|
||||
let interrupted = false
|
||||
for (let attempt = 0; attempt < 2500; attempt++) {
|
||||
const group = await savedJson(join(bindingRoot, 'commit.json'))
|
||||
if (group?.status === 'applying' && group.transactions?.worktree && !group.transactions.common) { interrupted = true; worker.kill(); break }
|
||||
if (worker.exitCode !== null) break
|
||||
await new Promise(done => setTimeout(done, 5))
|
||||
}
|
||||
if (!interrupted) { worker.kill(); await closed; throw new Error(`Did not observe the real mid-group interruption boundary: ${workerError}`) }
|
||||
await closed
|
||||
let crashRecovery: Awaited<ReturnType<typeof gitHostAction>> | undefined
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
try { crashRecovery = await gitHostAction('recover', context); break }
|
||||
catch (error) { if (!/active|正在|共享|sharing|win32=32/i.test((error as Error).message) || attempt === 49) throw error; await new Promise(done => setTimeout(done, 50)) }
|
||||
}
|
||||
check('actual host-process death between native phases recovers a determinate outcome', !!crashRecovery && ['not-applied', 'committed'].includes(crashRecovery.writeback ?? ''))
|
||||
if (crashRecovery?.writeback === 'not-applied') check('interrupted multi-root publication restores the original host files and refs', hostGit('rev-parse', 'HEAD').trim() === beforeCrashHead && (await fs.readFile(join(workspace, 'file.txt'))).equals(beforeCrashBytes))
|
||||
else check('an already completed group is acknowledged without compensating its committed state', hostGit('log', '-1', '--oneline').includes('crash-draft'))
|
||||
check('recovered private Git history remains available for explicit retry or inspection', (await run('git log -1 --oneline')).data.stdout.includes('crash-draft'))
|
||||
const cleanup = await cleanupGitControls()
|
||||
check('Git maintenance removes only abandoned controls after verifying owner/native lifetimes', cleanup.some(record => record.state === 'removed'))
|
||||
const liveControl = await createGitControl(await effectiveSandboxPolicy(workspace))
|
||||
try { check('maintenance preserves a live owned Git control', (await cleanupGitControls()).some(record => record.id === liveControl.root.split(/[\\/]/).at(-1) && record.state === 'active')) } finally { await liveControl.close() }
|
||||
if (crashRecovery?.writeback === 'not-applied') await gitHostAction('commit', context)
|
||||
check('a real pack larger than the ordinary 8 MiB file ceiling publishes through the Git metadata channel', hostGit('log', '-1', '--oneline').includes('crash-draft') && (await fs.readdir(join(workspace, '.git/objects/pack'))).some(name => name.endsWith('.pack') && name.startsWith('pack-')) && hostGit('fsck', '--connectivity-only').trim() === '')
|
||||
const foreignLock = join(workspace, '.git/index.lock'); await fs.writeFile(foreignLock, 'foreign-lock')
|
||||
await assert.rejects(run('git status --short'), /busy|lock/)
|
||||
check('a foreign Git lock is never consumed or removed by refresh', (await fs.readFile(foreignLock, 'utf8')) === 'foreign-lock')
|
||||
await fs.unlink(foreignLock)
|
||||
const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['secret.txt'] } })
|
||||
check('policy revocation makes the full history import stale', (await hostGitStatus(workspace, context.sessionId)).state === 'stale')
|
||||
await assert.rejects(run('git log -1'), /策略|policy/)
|
||||
const discarded = await workspaceAction('discard', context)
|
||||
check('discard under a new policy archives the old full-history grant without silently reimporting it', discarded.writeback === 'not-applied' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
const currentPolicy = await effectiveSandboxPolicy(workspace)
|
||||
check('the active policy remains in force after Git recovery and discard', currentPolicy.deniedPaths.includes('secret.txt'))
|
||||
console.log(`SANDBOX_GIT_SAFETY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_SAFETY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { build } from 'esbuild'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const temporary = join(project, '.tmp')
|
||||
await fs.mkdir(temporary, { recursive: true })
|
||||
const output = join(temporary, `sandbox-maintenance-cli-${randomUUID()}.mjs`)
|
||||
try {
|
||||
// One node entry point avoids npm's double cmd-shim escaping of path arguments.
|
||||
await build({ absWorkingDir: project, entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: output, logLevel: 'warning' })
|
||||
await import(pathToFileURL(output).href)
|
||||
} finally { await fs.unlink(output).catch(() => undefined) }
|
||||
@@ -1,135 +0,0 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { sandboxStatus, verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { releaseRunLease, sandboxRecords, sandboxOperation, sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { readSandboxPolicy, effectiveSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { sandboxGitState, resetSandboxGit } from '../src/main/sandbox/git-repository'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import type { SandboxWorkspaceAction } from '../src/shared/sandbox'
|
||||
import { gitHostAction } from '../src/main/sandbox/git-host-service'
|
||||
import { gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
|
||||
import { sandboxGitTool } from '../src/main/agent/tools/sandbox-git'
|
||||
import { importGitCredential, listGitCredentials, removeGitCredential, type GitCredential } from '../src/main/sandbox/git-credentials'
|
||||
import { regularBytes } from '../src/main/sandbox/workspace-files'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { cleanupGitControls } from '../src/main/sandbox/git-control'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function option(name: string): string | undefined {
|
||||
const index = args.indexOf(name)
|
||||
if (index < 0) return undefined
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing ${name} value`)
|
||||
args.splice(index, 2)
|
||||
return value
|
||||
}
|
||||
const stateRoot = option('--state-root'), resourceDirectory = option('--resources') ?? resolve('resources/sandbox/win32-x64')
|
||||
const workspace = option('--workspace')
|
||||
const expectedRevision = option('--expected-revision'), rulesFile = option('--rules')
|
||||
const source = option('--source')
|
||||
const sessionId = option('--session'), draftRevision = option('--draft-revision')
|
||||
const requestFile = option('--request'), credentialFile = option('--credential-file'), remoteUrl = option('--url')
|
||||
const credentialId = option('--credential-id')
|
||||
const approvedIndex = args.indexOf('--approve')
|
||||
const explicitlyApproved = approvedIndex >= 0
|
||||
if (explicitlyApproved) args.splice(approvedIndex, 1)
|
||||
const command = args.shift() ?? 'status'
|
||||
if (stateRoot && !isAbsolute(stateRoot)) throw new Error('--state-root must be an absolute directory')
|
||||
if (workspace && !isAbsolute(workspace)) throw new Error('--workspace must be an absolute directory')
|
||||
if (!['status', 'self-test'].includes(command) && !stateRoot) throw new Error('Stateful maintenance requires an explicit --state-root (the app userData/sandbox directory)')
|
||||
const temporary = !stateRoot ? await fs.mkdtemp(resolve('.tmp/sandbox-maintenance-')) : stateRoot
|
||||
configureSandbox({ stateRoot: temporary, resourceDirectory })
|
||||
if (command === 'status') console.log(JSON.stringify(await sandboxStatus(), null, 2))
|
||||
else if (command === 'toolchains') console.log(JSON.stringify(await listSandboxToolchains(), null, 2))
|
||||
else if (command.startsWith('git-host-')) {
|
||||
const action = command.slice('git-host-'.length)
|
||||
if (!workspace || !sessionId || !['status', 'init', 'import', 'changes', 'commit', 'discard', 'recover'].includes(action)) throw new Error('Usage: git-host-status|init|import|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--approve]')
|
||||
console.log(JSON.stringify(await gitHostAction(action as 'status' | 'init' | 'import' | 'changes' | 'commit' | 'discard' | 'recover', { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
|
||||
}
|
||||
else if (command === 'git-remote' || command === 'git-run' || command === 'git-remote-operation' || command === 'git-remote-reconcile') {
|
||||
if (!workspace || !sessionId) throw new Error('Git requires explicit --workspace and --session')
|
||||
const context = { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }
|
||||
if (command === 'git-remote-operation' || command === 'git-remote-reconcile') console.log(JSON.stringify(await (command === 'git-remote-reconcile' ? reconcileGitRemote(context, args.shift() ?? '', credentialId) : gitRemoteOperation(context, args.shift() ?? '')), null, 2))
|
||||
else {
|
||||
if (!requestFile || !isAbsolute(requestFile)) throw new Error('Git requires an absolute --request JSON file')
|
||||
const request = JSON.parse((await regularBytes(requestFile, 64 * 1024)).toString('utf8'))
|
||||
if (command === 'git-run') {
|
||||
if (!Array.isArray(request) || !request.length || request.some(value => typeof value !== 'string')) throw new Error('git-run request must be an argv array')
|
||||
console.log(JSON.stringify(await executeSandboxCommand(`git ${request.join(' ')}`, { ...context, runtimes: ['git'], workspaceMode: 'persistent', gitArguments: request, preserveGitConflicts: true }), null, 2))
|
||||
} else {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request) || !['ls-remote', 'clone', 'fetch', 'pull', 'push'].includes(request.action)) throw new Error('git-remote request requires a supported remote action')
|
||||
console.log(JSON.stringify(await sandboxGitTool.run(request, context), null, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (command === 'git-credential-add') {
|
||||
if (!remoteUrl || !credentialFile || !isAbsolute(credentialFile)) throw new Error('Usage: git-credential-add --url REMOTE_URL --credential-file ABSOLUTE_JSON --state-root ROOT')
|
||||
const credential = JSON.parse((await regularBytes(credentialFile, 64 * 1024)).toString('utf8')) as GitCredential
|
||||
console.log(JSON.stringify(await importGitCredential(remoteUrl, credential), null, 2))
|
||||
}
|
||||
else if (command === 'git-credentials') console.log(JSON.stringify(await listGitCredentials(), null, 2))
|
||||
else if (command === 'git-cleanup') console.log(JSON.stringify(await cleanupGitControls(), null, 2))
|
||||
else if (command === 'git-credential-remove') { await removeGitCredential(args.shift() ?? ''); console.log(JSON.stringify({ removed: true })) }
|
||||
else if (command.startsWith('workspace-')) {
|
||||
const action = command.slice('workspace-'.length) as SandboxWorkspaceAction
|
||||
if (!['status', 'changes', 'commit', 'discard', 'recover'].includes(action) || !workspace || !sessionId) throw new Error('Usage: workspace-status|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--draft-revision UUID] [--approve]')
|
||||
console.log(JSON.stringify(await workspaceAction(action, { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
|
||||
}
|
||||
else if (command === 'toolchain-add') {
|
||||
const kind = args.shift()
|
||||
if (!kind || !source) throw new Error('Usage: toolchain-add node|python|git --source ABSOLUTE_EXE --state-root ROOT')
|
||||
console.log(JSON.stringify(await importSandboxToolchain(kind, source), null, 2))
|
||||
}
|
||||
else if (command === 'git-state' || command === 'git-reset') {
|
||||
if (!workspace) throw new Error('Usage: git-state|git-reset --workspace ABSOLUTE_ROOT --state-root ROOT')
|
||||
if (command === 'git-reset') await resetSandboxGit(workspace)
|
||||
console.log(JSON.stringify(await sandboxGitState(workspace), null, 2))
|
||||
}
|
||||
else if (command === 'dependency-add') {
|
||||
const kind = args.shift()
|
||||
if (!kind || !workspace || !isAbsolute(workspace)) throw new Error('Usage: dependency-add node|python --workspace ABSOLUTE_ROOT --state-root ROOT')
|
||||
console.log(JSON.stringify(await importSandboxDependencies(workspace, kind), null, 2))
|
||||
}
|
||||
else if (command === 'policy') console.log(JSON.stringify(workspace ? await effectiveSandboxPolicy(workspace) : await readSandboxPolicy(), null, 2))
|
||||
else if (command === 'operation') {
|
||||
const id = args.shift()
|
||||
if (!id) throw new Error('Usage: operation RUN_UUID --state-root ROOT')
|
||||
console.log(JSON.stringify(await sandboxOperation(id), null, 2))
|
||||
}
|
||||
else if (command === 'broker-operation') {
|
||||
const runId = args.shift(), requestId = args.shift()
|
||||
if (!runId || !requestId) throw new Error('Usage: broker-operation RUN_UUID REQUEST_UUID --state-root ROOT')
|
||||
console.log(JSON.stringify(await sandboxBrokerOperation(runId, requestId), null, 2))
|
||||
}
|
||||
else if (command === 'policy-set') {
|
||||
if (!expectedRevision || !rulesFile) throw new Error('Usage: policy-set --expected-revision REVISION --rules RULES_JSON --state-root ROOT [--workspace ROOT]')
|
||||
const stat = await fs.stat(rulesFile)
|
||||
if (!stat.isFile() || stat.size > 64 * 1024) throw new Error('Rules input must be a JSON file smaller than 64 KiB')
|
||||
const rules = JSON.parse(await fs.readFile(rulesFile, 'utf8'))
|
||||
console.log(JSON.stringify(await updateSandboxPolicy({ expectedRevision, rules, ...(workspace ? { workspaceRoot: workspace } : {}) }), null, 2))
|
||||
}
|
||||
else if (command === 'audit' || command === 'transactions') console.log(JSON.stringify(await sandboxRecords(command === 'audit' ? 'runs' : 'transactions'), null, 2))
|
||||
else if (command === 'restore') {
|
||||
const id = args.shift()
|
||||
if (!id || !workspace || !isAbsolute(workspace)) throw new Error('Usage: restore <transaction-id> --workspace <absolute-root> --state-root <absolute-state>')
|
||||
await restoreTransaction(id, workspace)
|
||||
console.log(JSON.stringify({ restored: id, workspace }))
|
||||
} else if (command === 'cleanup') {
|
||||
const binary = await verifiedBinary()
|
||||
const root = join(temporary, 'runs')
|
||||
const directories = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const results = []
|
||||
for (const entry of directories) {
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink() && /^[0-9a-f-]{36}$/.test(entry.name)) results.push({ id: entry.name, status: await releaseRunLease(binary, join(root, entry.name)) })
|
||||
}
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
} else if (command === 'self-test') {
|
||||
const result = await runSandboxSelfTest()
|
||||
if (!result.passed) throw new Error('Native sandbox self-test failed')
|
||||
console.log(JSON.stringify({ ...result, stateRoot: temporary }))
|
||||
} else throw new Error('Commands: status, self-test, policy, policy-set, toolchains, toolchain-add, dependency-add, git-state, git-reset, operation, broker-operation, audit, transactions, restore, cleanup')
|
||||
@@ -1,179 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { readSandboxPolicy, updateSandboxPolicy, effectiveSandboxPolicy, validatePolicyOverride } from '../src/main/sandbox/policy-store'
|
||||
import { authorizeSandboxWrite } from '../src/main/sandbox/authorizations'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
|
||||
import { digest, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-policy-'))
|
||||
const workspace = join(fixture, 'workspace'), other = join(fixture, 'other'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(other)
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
await fs.mkdir(join(workspace, 'private')); await fs.writeFile(join(workspace, 'private/file.txt'), 'private-dummy')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let document = await readSandboxPolicy()
|
||||
const context = { workspaceRoot: workspace, sessionId: 'policy-session', runId: 'policy-run', toolCallId: 'policy-tool', approve: async () => true }
|
||||
async function update(rules: Parameters<typeof updateSandboxPolicy>[0]['rules'], root?: string): Promise<void> {
|
||||
document = await updateSandboxPolicy({ expectedRevision: document.revision, rules, ...(root ? { workspaceRoot: root } : {}) })
|
||||
}
|
||||
try {
|
||||
check('missing policy loads safe deterministic defaults without writing state', document.defaults.commandsEnabled && document.defaults.writeback === 'ask' && document.updatedAt === 0 && !await fs.stat(join(stateRoot, 'policy.json')).catch(() => null))
|
||||
for (const value of [{ network: 'allow' }, { writeback: 'automatic' }, { limits: { files: -1 } }, { limits: { timeoutMs: Infinity } }, { deniedPaths: ['../escape'] }, { deniedPaths: ['C:/host'] }, { deniedPaths: ['a\\..\\escape'] }]) assert.throws(() => validatePolicyOverride(value))
|
||||
check('unsupported permissions and malformed policies cannot be enabled', true)
|
||||
const initialRevision = document.revision
|
||||
await update({ deniedPaths: ['private'], limits: { files: 500 } })
|
||||
check('policy is atomically persisted with a new revision', document.revision !== initialRevision && (await readSandboxPolicy()).revision === document.revision)
|
||||
await assert.rejects(updateSandboxPolicy({ expectedRevision: initialRevision, rules: { commandsEnabled: false } }), /版本冲突/)
|
||||
check('stale policy compare-and-swap is rejected', (await readSandboxPolicy()).defaults.commandsEnabled)
|
||||
await update({ commandsEnabled: false, deniedPaths: ['workspace-only'], limits: { files: 10 } }, workspace)
|
||||
check('workspace policy applies only to its canonical workspace', !(await effectiveSandboxPolicy(workspace)).commandsEnabled && (await effectiveSandboxPolicy(other)).commandsEnabled)
|
||||
await update({ commandsEnabled: true, deniedPaths: [] }, workspace)
|
||||
check('workspace overrides cannot remove global path restrictions', (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('private'))
|
||||
check('workspace limits cannot widen global limits', (await effectiveSandboxPolicy(workspace)).limits.files === 10)
|
||||
let tool = await executeTool('read_file', { path: 'private/file.txt' }, context)
|
||||
check('host file reads enforce the effective denied-path policy', !tool.ok && !tool.content.includes('private-dummy'))
|
||||
tool = await executeTool('write_file', { path: 'private/file.txt', content: 'unsafe' }, context)
|
||||
check('host writes cannot bypass denied paths', !tool.ok && await fs.readFile(join(workspace, 'private/file.txt'), 'utf8') === 'private-dummy')
|
||||
await update({ limits: { files: 500 } }, workspace)
|
||||
const targets = digest(Buffer.from('target-and-content'))
|
||||
let policy = await effectiveSandboxPolicy(workspace)
|
||||
let ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
assert.ok(ticket)
|
||||
await assert.rejects(ticket.consume(digest(Buffer.from('another-target'))), /目标或内容/)
|
||||
await ticket.consume(targets)
|
||||
await assert.rejects(ticket.consume(targets), /已使用/)
|
||||
check('authorization binds the exact operation and is single use', true)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
context.toolCallId = 'another-call'
|
||||
await assert.rejects(ticket!.consume(targets), /归属/)
|
||||
context.toolCallId = 'policy-tool'
|
||||
check('authorization rejects changed call ownership', true)
|
||||
const substitutedOwner = { ...context, approve: async () => { substitutedOwner.toolCallId = 'substituted-during-approval'; return true } }
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedOwner, targets), /归属/)
|
||||
check('approval captures ownership before waiting for the user', true)
|
||||
const substitutedWorkspace = { ...context, approve: async () => { substitutedWorkspace.workspaceRoot = other; return true } }
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedWorkspace, targets), /归属/)
|
||||
check('approval cannot migrate to a different workspace while pending', true)
|
||||
const abort = new AbortController()
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, signal: abort.signal, approve: async () => { abort.abort(); return true } }, targets), /取消/)
|
||||
check('cancellation during approval never grants a ticket', true)
|
||||
const now = Date.now
|
||||
try {
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, approve: async () => { Date.now = () => now() + 11 * 60_000; return true } }, targets), /过期/)
|
||||
} finally { Date.now = now }
|
||||
check('an approval arriving after its deadline is rejected', true)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
await update({ maxChanges: 3 })
|
||||
await assert.rejects(ticket!.consume(targets), /策略已变化/)
|
||||
check('policy updates revoke existing authorization', true)
|
||||
policy = await effectiveSandboxPolicy(workspace)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
const unchangedRevisionBytes = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
const editedWithoutRevision = JSON.parse(unchangedRevisionBytes); editedWithoutRevision.defaults.commandsEnabled = false
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(editedWithoutRevision))
|
||||
await assert.rejects(ticket!.consume(targets), /策略已变化/)
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), unchangedRevisionBytes)
|
||||
await assert.rejects(ticket!.consume(targets), /过期|归属/)
|
||||
check('policy content changes without a new revision still revoke approval permanently', true)
|
||||
let approvals = 0
|
||||
tool = await executeTool('write_file', { path: 'sample.txt', content: 'after' }, { ...context, approve: async () => { approvals++; await update({ maxChanges: 4 }); return true } })
|
||||
check('a policy update while the user approves a host write prevents mutation', !tool.ok && approvals === 1 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const args = { path: 'sample.txt', content: 'approved-content' }
|
||||
tool = await executeTool('write_file', args, { ...context, approve: async () => { args.content = 'substituted'; return true } })
|
||||
check('approved host content cannot be substituted by mutating the request', tool.ok && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'approved-content')
|
||||
const transactionId = (tool.data as { transactionId: string }).transactionId
|
||||
const transaction = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId, 'manifest.json'), 'utf8'))
|
||||
check('transaction persists policy, authorization and session identity', transaction.identity.sessionId === context.sessionId && transaction.identity.policyRevision === document.revision && !!transaction.identity.authorizationId)
|
||||
await restoreTransaction(transactionId, workspace)
|
||||
await update({ writeback: 'deny' })
|
||||
approvals = 0
|
||||
tool = await executeTool('write_file', { path: 'sample.txt', content: 'denied' }, { ...context, approve: async () => { approvals++; return true } })
|
||||
check('deny-writeback policy applies before prompting', !tool.ok && approvals === 0 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
await update({ writeback: 'ask' })
|
||||
if (process.platform !== 'win32') throw new Error('This suite includes real Windows native checks; unsupported hosts cannot report a pass')
|
||||
let result = await executeSandboxCommand('type private\\file.txt', context)
|
||||
check('command snapshots enforce the same denied paths as host tools', !result.ok && !result.data.stdout.includes('private-dummy'))
|
||||
await assert.rejects(executeSandboxCommand('mkdir private & echo denied>private\\new.txt', context), /受保护路径|策略/)
|
||||
check('command-generated denied paths cannot reach the host', !await fs.stat(join(workspace, 'private/new.txt')).catch(() => null))
|
||||
await update({ commandsEnabled: false })
|
||||
await assert.rejects(executeSandboxCommand('echo unsafe>disabled.txt', context), /禁用/)
|
||||
check('command-disable policy is enforced by the native route', !await fs.stat(join(workspace, 'disabled.txt')).catch(() => null))
|
||||
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
|
||||
await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/)
|
||||
check('configured snapshot byte limits are enforced before execution', !await fs.stat(join(workspace, 'size.txt')).catch(() => null))
|
||||
await update({ limits: { fileBytes: 256 * 1024 * 1024 } })
|
||||
result = await executeSandboxCommand('echo approved>sample.txt', context)
|
||||
check('native writeback records policy and authorization identities', result.ok && result.data.policyRevision === document.revision && !!result.data.authorizationId)
|
||||
const operation = await sandboxOperation(result.data.runId)
|
||||
check('host commit outcome can be queried independently of the chat response', operation.writeback === 'committed' && operation.transactionId === result.data.transactionId && operation.identity.sessionId === context.sessionId)
|
||||
await assert.rejects(sandboxOperation('../outside'), /标识无效/)
|
||||
check('operation queries reject path traversal identities', true)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const selfTest = await runSandboxSelfTest()
|
||||
check('self-test persists a binary and policy-bound native observation', selfTest.passed && (await sandboxStatus()).selfTest?.runId === selfTest.runId)
|
||||
const beforeManualEdit = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
const modified = JSON.parse(beforeManualEdit); modified.defaults.maxChanges = 9
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(modified))
|
||||
check('same-revision manual policy edits invalidate a self-test observation', !(await sandboxStatus()).selfTest)
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), beforeManualEdit)
|
||||
await update({ maxChanges: 2 })
|
||||
check('policy changes invalidate an old self-test observation', !(await sandboxStatus()).selfTest)
|
||||
policy = await effectiveSandboxPolicy(workspace)
|
||||
const liveCommand = executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', context)
|
||||
const cancelByPolicy = setTimeout(() => { void update({ commandsEnabled: false }).catch(() => undefined) }, 900)
|
||||
await assert.rejects(liveCommand, /策略已变化/)
|
||||
clearTimeout(cancelByPolicy)
|
||||
check('an external policy revision stops an active native process', true)
|
||||
await update({ commandsEnabled: true })
|
||||
const approvalController = new AbortController(), events: ChatEvent[] = []
|
||||
let pendingReady!: (id: string) => void
|
||||
const ready = new Promise<string>(done => { pendingReady = done })
|
||||
const emit = (event: ChatEvent): void => { events.push(event); if (event.type === 'interaction' && event.interaction.status === 'pending') pendingReady(event.interaction.id) }
|
||||
const broker = createInteractionBroker(context.runId, approvalController.signal, emit)
|
||||
const pendingWrite = executeTool('write_file', { path: 'revoked.txt', content: 'must-not-write' }, { ...context, signal: approvalController.signal,
|
||||
approve: async (title, cancellation) => (await broker.approval(context.toolCallId, title, [{ optionId: 'allow-once', name: 'allow', kind: 'allow_once' }], undefined, cancellation)).optionId === 'allow-once' })
|
||||
const interactionId = await ready
|
||||
await update({ maxChanges: 3 })
|
||||
const revokedWrite = await pendingWrite
|
||||
check('policy revocation cancels the actual pending conversation interaction', !revokedWrite.ok && events.some(event => event.type === 'interaction' && event.interaction.id === interactionId && event.interaction.status === 'cancelled'))
|
||||
check('a revoked interaction cannot accept a later approval response', !respondToInteraction(context.runId, interactionId, { kind: 'approval', optionId: 'allow-once' }, emit) && !await fs.stat(join(workspace, 'revoked.txt')).catch(() => null))
|
||||
let releaseApproval!: () => void, approvalStarted!: () => void
|
||||
const waitApproval = new Promise<boolean>(done => { releaseApproval = () => done(true) }), approvalIsPending = new Promise<void>(done => { approvalStarted = done })
|
||||
const runCount = (await fs.readdir(join(stateRoot, 'runs'))).length
|
||||
const first = executeSandboxCommand('echo serialized-first>serialized.txt', { ...context, approve: async () => { approvalStarted(); return waitApproval } })
|
||||
await approvalIsPending
|
||||
const second = executeSandboxCommand('type serialized.txt', context)
|
||||
const queuedAbort = new AbortController()
|
||||
const third = executeSandboxCommand('echo should-not-run>queued-cancelled.txt', { ...context, signal: queuedAbort.signal })
|
||||
const thirdRejected = assert.rejects(third, /取消/)
|
||||
queuedAbort.abort(); await thirdRejected
|
||||
await new Promise(done => setTimeout(done, 50))
|
||||
check('same-workspace commands and cancelled queued requests do not start stale snapshots', (await fs.readdir(join(stateRoot, 'runs'))).length === runCount + 1)
|
||||
releaseApproval()
|
||||
const firstResult = await first, secondResult = await second
|
||||
check('a queued command observes the previous approved host commit', firstResult.ok && secondResult.ok && secondResult.data.stdout.includes('serialized-first') && !await fs.stat(join(workspace, 'queued-cancelled.txt')).catch(() => null))
|
||||
await restoreTransaction(firstResult.data.transactionId!, workspace)
|
||||
const original = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), '{malformed')
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>corrupt.txt' }, context)
|
||||
check('corrupt policy fails closed without host execution', !tool.ok && !await fs.stat(join(workspace, 'corrupt.txt')).catch(() => null))
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), original)
|
||||
await fs.writeFile(join(stateRoot, 'policy.lock'), 'existing-foreign-maintenance-lock')
|
||||
await assert.rejects(updateSandboxPolicy({ expectedRevision: document.revision, rules: {} }), /其他维护进程/)
|
||||
check('policy updates respect an existing cross-process maintenance lock', await fs.readFile(join(stateRoot, 'policy.lock'), 'utf8') === 'existing-foreign-maintenance-lock')
|
||||
await fs.unlink(join(stateRoot, 'policy.lock'))
|
||||
console.log(`SANDBOX_POLICY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Trusted-process authorization contracts and real Windows command integration; child IPC authentication and original WorkBuddy parity remain separate.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_POLICY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-process-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace)
|
||||
const outside = join(fixture, 'outside.txt'); await fs.writeFile(outside, 'OUTSIDE_FIXTURE')
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, runtimes: ['node'], timeoutMs: 10_000 }
|
||||
const source = String.raw`
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');
|
||||
const labels=[];function check(label,fn){fn();labels.push(label);console.log('PROCESS_CHECK '+label)}
|
||||
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'node-runtime-'));
|
||||
try{
|
||||
check('private-temp',()=>{fs.writeFileSync(path.join(scratch,'x.txt'),'private');assert.equal(fs.readFileSync(path.join(scratch,'x.txt'),'utf8'),'private');fs.unlinkSync(path.join(scratch,'x.txt'))});
|
||||
check('binary-stdout-stderr',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(Buffer.from([0,255,1,2]));process.stderr.write("diagnostic")']);assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,1,2]));assert.equal(r.stderr.toString(),'diagnostic')});
|
||||
check('stdin',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdin.on("data",x=>process.stdout.write(x))'],{input:Buffer.from([0,255,19])});assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,19]))});
|
||||
check('cwd-and-env',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(process.cwd()+"|"+process.env.YCODE_FIXTURE)'],{cwd:scratch,env:{...process.env,YCODE_FIXTURE:'private-value'},encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,scratch+'|private-value')});
|
||||
check('exec-file',()=>assert.equal(cp.execFileSync(process.execPath,['-e','process.stdout.write("file-result")'],{encoding:'utf8'}),'file-result'));
|
||||
check('exec-shell',()=>assert.equal(cp.execSync('echo shell-result',{encoding:'utf8'}).trim(),'shell-result'));
|
||||
check('omitted-args-options',()=>assert.equal(cp.execFileSync(process.execPath,undefined,{input:'process.stdout.write("stdin-script")',encoding:'utf8'}),'stdin-script'));
|
||||
check('nonzero-status',()=>{assert.throws(()=>cp.execFileSync(process.execPath,['-e','process.stderr.write("expected-error");process.exit(7)'],{encoding:'utf8'}),e=>e.status===7&&e.stderr==='expected-error')});
|
||||
check('missing-executable',()=>{const r=cp.spawnSync('definitely-missing-ycode-program.exe');assert.equal(r.error.code,'ENOENT');assert.equal(r.status,null)});
|
||||
check('timeout',()=>{const r=cp.spawnSync(process.execPath,['-e','setTimeout(()=>console.log("late"),3000)'],{timeout:150,encoding:'utf8'});assert.equal(r.error.code,'ETIMEDOUT');assert.ok(!r.stdout.includes('late'))});
|
||||
check('max-buffer',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write("x".repeat(2048))'],{maxBuffer:128,encoding:'utf8'});assert.equal(r.error.code,'ENOBUFS');assert.equal(r.stdout.length,128)});
|
||||
check('inherited-stdio',()=>assert.equal(cp.spawnSync(process.execPath,['-e','console.log("INHERITED_STDIO_PASS")'],{stdio:'inherit'}).status,0));
|
||||
check('esm-exports',()=>{const r=cp.spawnSync(process.execPath,['--require',process.env.YCODE_SANDBOX_PROCESS,'--input-type=module','-e','import {execSync} from "node:child_process";console.log(execSync("echo ESM_CAPTURE_PASS",{encoding:"utf8"}).trim())'],{encoding:'utf8'});assert.equal(r.status,0);assert.ok(r.stdout.includes('ESM_CAPTURE_PASS'))});
|
||||
check('adapter-readonly',()=>{assert.throws(()=>fs.appendFileSync(process.env.YCODE_SANDBOX_PROCESS,'x'),e=>['EACCES','EPERM'].includes(e.code))});
|
||||
check('host-boundary',()=>{const r=cp.spawnSync(process.execPath,['-e',__HOST_PROBE__],{encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,'HOST_DENIED')});
|
||||
check('scratch-cleanup',()=>assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(name=>name.startsWith('ycode-process-')),[]));
|
||||
console.log('PROCESS_LABELS '+JSON.stringify(labels));
|
||||
}finally{fs.rmdirSync(scratch)}
|
||||
`
|
||||
try {
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
const hostProbe = `try{require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9)}catch(e){if(!['EACCES','EPERM'].includes(e.code))throw e;process.stdout.write('HOST_DENIED')}`
|
||||
await fs.writeFile(join(workspace, 'process.cjs'), source.replace('__HOST_PROBE__', JSON.stringify(hostProbe)))
|
||||
const result = await executeSandboxCommand('node --require "%YCODE_SANDBOX_PROCESS%" process.cjs', context)
|
||||
assert.ok(result.ok, result.content)
|
||||
const labels = JSON.parse(result.data.stdout.split(/\r?\n/).find(line => line.startsWith('PROCESS_LABELS '))!.slice(15)) as string[]
|
||||
for (const label of labels) check(`actual AppContainer Node ${label}`, true)
|
||||
check('process adapter creates no host writeback or network authority', result.data.writeback === 'unchanged' && result.data.profileRemoved && result.data.driveRemoved)
|
||||
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
|
||||
await importSandboxToolchain('python', await fs.realpath(pythonSource))
|
||||
const outsideDirectory = join(fixture, 'outside-directory')
|
||||
await fs.writeFile(join(workspace, 'temp.py'), [
|
||||
'import tempfile, pathlib, subprocess, sys, os, sitecustomize',
|
||||
'with tempfile.TemporaryDirectory() as directory:',
|
||||
' path = pathlib.Path(directory) / "data.txt"',
|
||||
' path.write_text("private-python", encoding="utf-8")',
|
||||
' assert path.read_text() == "private-python"',
|
||||
' result = subprocess.run([sys.executable, "-c", "print(43)"], capture_output=True, text=True, timeout=3)',
|
||||
' assert result.returncode == 0 and result.stdout.strip() == "43"',
|
||||
' private = pathlib.Path(directory) / "mode700"',
|
||||
' os.mkdir(private, 0o700)',
|
||||
' (private / "child.txt").write_text("accessible")',
|
||||
' assert (private / "child.txt").read_text() == "accessible"',
|
||||
`try: os.mkdir(${JSON.stringify(outsideDirectory)}, 0o700)`,
|
||||
'except PermissionError: pass',
|
||||
'else: raise AssertionError("outside directory unexpectedly created")',
|
||||
'try: pathlib.Path(sitecustomize.__file__).write_text("tamper")',
|
||||
'except PermissionError: pass',
|
||||
'else: raise AssertionError("adapter unexpectedly writable")',
|
||||
'print("PYTHON_TEMP_CAPTURE_PASS")', ''
|
||||
].join('\n'))
|
||||
const python = await executeSandboxCommand('python temp.py', { ...context, runtimes: ['python'] })
|
||||
assert.ok(python.ok, python.content)
|
||||
check('actual Python private tempfile and anonymous-pipe subprocess capture work', python.ok && python.data.stdout.includes('PYTHON_TEMP_CAPTURE_PASS') && python.data.writeback === 'unchanged')
|
||||
check('Python mode700 compatibility retains native host denial and read-only adapter', !await fs.stat(outsideDirectory).catch(() => null))
|
||||
const components = join(fixture, 'components')
|
||||
await fs.mkdir(components)
|
||||
for (const name of ['ycode-sandbox.exe', 'manifest.json', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) await fs.copyFile(resolve('resources/sandbox/win32-x64', name), join(components, name))
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: components })
|
||||
assert.equal((await sandboxStatus()).state, 'installed')
|
||||
for (const name of ['node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) {
|
||||
const original = await fs.readFile(join(components, name))
|
||||
await fs.appendFile(join(components, name), 'tamper')
|
||||
assert.equal((await sandboxStatus()).state, 'unavailable')
|
||||
await assert.rejects(executeSandboxCommand('echo MUST_NOT_RUN', context), /摘要/)
|
||||
await fs.writeFile(join(components, name), original)
|
||||
}
|
||||
check('a tampered shipped adapter invalidates installed status and fails commands closed', true)
|
||||
console.log(`SANDBOX_PROCESS_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Actual AppContainer Node explicit synchronous file-stdio adapter and Python temporary-directory/capture regression. Default Node pipes and asynchronous spawn/fork compatibility remain unverified/unsupported.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_PROCESS_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
|
||||
import { executeSandboxCommand, verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Read bridge regression requires actual Windows/NTFS/AppContainer execution')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-bridge-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(join(fixture, 'outside'))
|
||||
const outside = join(fixture, 'outside', 'host.txt')
|
||||
await fs.writeFile(outside, 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const bytes = Buffer.alloc(83_171); for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251
|
||||
const expectedHash = createHash('sha256').update(bytes).digest('hex')
|
||||
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
||||
await fs.writeFile(join(workspace, '.env'), 'SECRET_MUST_NOT_BE_LISTED_OR_RETURNED')
|
||||
await fs.mkdir(join(workspace, '\u76ee\u5f55 with spaces')); await fs.writeFile(join(workspace, '\u76ee\u5f55 with spaces', 'text.txt'), 'hello-unicode-path')
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const pause = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
|
||||
const context = { workspaceRoot: workspace, runId: 'host-bound-run', toolCallId: 'host-bound-tool', sessionId: 'host-bound-session', runtimes: ['node'], readBridge: true, timeoutMs: 10_000 }
|
||||
let caseId = 0
|
||||
async function run(source: string) {
|
||||
const name = `case-${++caseId}.cjs`
|
||||
await fs.writeFile(join(workspace, name), source)
|
||||
return executeSandboxCommand(`node ${name}`, context)
|
||||
}
|
||||
const load = "const assert=require('node:assert/strict');const mod=require(process.env.YCODE_SANDBOX_BRIDGE);"
|
||||
try {
|
||||
const policy = await effectiveSandboxPolicy(workspace), binary = await verifiedBinary(), reader = await NativeWorkspaceReader.create(binary, policy)
|
||||
let result = await reader.read('read', 'data.bin')
|
||||
check('native pinned file reads preserve exact binary bytes and root identity', Buffer.from(result.data!, 'base64').equals(bytes) && result.rootIdentity === reader.rootIdentity)
|
||||
result = await reader.read('read', '\u76ee\u5f55 with spaces/text.txt')
|
||||
check('native canonical paths support Unicode and spaces', Buffer.from(result.data!, 'base64').toString() === 'hello-unicode-path')
|
||||
result = await reader.read('list', '.')
|
||||
check('directory enumeration uses its held native handle and filters protected names', result.entries!.some(entry => entry.name === 'data.bin') && !result.entries!.some(entry => entry.name === '.env'))
|
||||
for (const path of ['.env', '../outside/host.txt', outside, 'data.bin:stream', '\\\\localhost\\C$\\host.txt', '\\\\?\\C:\\host.txt', 'CON', 'data.bin.', 'data.bin ', '.git/config']) await assert.rejects(reader.read('read', path))
|
||||
check('credentials, traversal, absolute/device/UNC/ADS and ambiguous names are denied', true)
|
||||
await fs.symlink(join(fixture, 'outside'), join(workspace, 'junction'), 'junction')
|
||||
await assert.rejects(reader.read('read', 'junction/host.txt'))
|
||||
await fs.unlink(join(workspace, 'junction'))
|
||||
await fs.link(outside, join(workspace, 'hard.txt')); await assert.rejects(reader.read('read', 'hard.txt')); await fs.unlink(join(workspace, 'hard.txt'))
|
||||
check('native reads reject junction escape and multiple hard links', true)
|
||||
const writer = await fs.open(join(workspace, 'data.bin'), 'r+')
|
||||
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'EBUSY') }
|
||||
finally { await writer.close() }
|
||||
check('an already-open writable handle prevents a read lock rather than weakening sharing', true)
|
||||
const replacement = join(fixture, 'replacement'), original = join(fixture, 'original')
|
||||
await fs.mkdir(replacement); await fs.writeFile(join(replacement, 'data.bin'), 'different-workspace')
|
||||
await fs.rename(workspace, original); await fs.rename(replacement, workspace)
|
||||
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'ESTALE') }
|
||||
finally { await fs.rename(workspace, replacement); await fs.rename(original, workspace) }
|
||||
check('replacing the workspace root invalidates the native identity even at the same path', true)
|
||||
await fs.mkdir(join(workspace, 'swap')); await fs.writeFile(join(workspace, 'swap/good.txt'), 'inside')
|
||||
await fs.writeFile(join(fixture, 'outside/good.txt'), 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
||||
let attacking = true, swaps = 0
|
||||
async function retry(action: () => Promise<void>): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await action(); return }
|
||||
catch (error) { if (attempt >= 500 || !['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error; await pause(2) }
|
||||
}
|
||||
}
|
||||
const attacker = (async () => {
|
||||
while (attacking) {
|
||||
let moved = false
|
||||
try {
|
||||
await fs.rename(join(workspace, 'swap'), join(workspace, 'swap-held'))
|
||||
moved = true
|
||||
await fs.symlink(join(fixture, 'outside'), join(workspace, 'swap'), 'junction'); swaps++; await pause(1)
|
||||
} catch (error) { if (!['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error }
|
||||
finally {
|
||||
// Windows may leave an empty directory when setting the junction races our read lock.
|
||||
if (moved) await retry(async () => {
|
||||
const stat = await fs.lstat(join(workspace, 'swap')).catch(error => { if (error.code === 'ENOENT') return null; throw error })
|
||||
if (stat?.isSymbolicLink()) await fs.unlink(join(workspace, 'swap'))
|
||||
else if (stat) await fs.rmdir(join(workspace, 'swap'))
|
||||
})
|
||||
if (moved) await retry(() => fs.rename(join(workspace, 'swap-held'), join(workspace, 'swap')))
|
||||
}
|
||||
await pause(1)
|
||||
}
|
||||
})()
|
||||
try {
|
||||
for (let index = 0; index < 40; index++) {
|
||||
try { const read = await reader.read('read', 'swap/good.txt'); assert.equal(Buffer.from(read.data!, 'base64').toString(), 'inside') }
|
||||
catch (error) { if (error instanceof assert.AssertionError) throw error; assert.ok(['ENOENT', 'EACCES', 'EBUSY'].includes((error as any).code)) }
|
||||
}
|
||||
} finally { attacking = false; await attacker }
|
||||
check('concurrent ancestor replacement with a real junction never returns outside bytes', swaps > 0)
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
await fs.copyFile(binary, join(workspace, 'host-reader.exe'))
|
||||
const sync = await run(load + `const c=mod.connectSync(); process.stdout.write('NORMAL_NO_NEWLINE');const b=c.readFileSync('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});assert.equal(c.statSync('data.bin').kind,'file');assert.ok(c.readdirSync('.').some(e=>e.name==='data.bin'));assert.throws(()=>c.readFileSync('.env'),{code:'EACCES'});assert.throws(()=>c.call('workspace.writeFile',{path:'data.bin'}),{code:'EACCES'});assert.throws(()=>c.call('workspace.stat',{path:'data.bin',workspaceRoot:'fake'}),{code:'EACCES'});c.close();console.log('SYNC_BRIDGE_PASS')`)
|
||||
check('real AppContainer Node sync client reads multiple binary chunks with backpressure', sync.ok && sync.data.stdout.includes('SYNC_BRIDGE_PASS') && sync.data.stdout.includes('NORMAL_NO_NEWLINE') && sync.data.writeback === 'unchanged')
|
||||
check('host writes remain unavailable over the read bridge', (await fs.readFile(join(workspace, 'data.bin'))).equals(bytes))
|
||||
const nested = await run(load + `const result=require('child_process').spawnSync('.\\\\host-reader.exe',['--workspace-read','identity',${JSON.stringify(workspace)},'.','-','1024'],{stdio:'inherit'});assert.equal(result.status,125);try{require('fs').readFileSync(${JSON.stringify(outside)});throw Error('host path unexpectedly readable')}catch(e){assert.ok(['EACCES','EPERM'].includes(e.code))}console.log('NATIVE_HOST_ONLY_PASS')`)
|
||||
check('a descendant cannot invoke the host-only reader or use ordinary fs to bypass the bridge', nested.ok && nested.data.stdout.includes('NATIVE_HOST_ONLY_PASS') && nested.data.stderr.includes('host-only'))
|
||||
const queryId = randomUUID()
|
||||
const asynchronous = await run(load + `(async()=>{const c=mod.connect();try{const results=await Promise.all(Array.from({length:12},()=>c.stat('data.bin')));assert.ok(results.every(r=>r.kind==='file'));const b=await c.readFile('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});await c.call('workspace.stat',{path:'data.bin'},${JSON.stringify(queryId)});assert.equal((await c.query(${JSON.stringify(queryId)})).state,'succeeded');await assert.rejects(c.query(${JSON.stringify(randomUUID())}),{code:'ENOENT'});console.log('ASYNC_BRIDGE_PASS')}finally{c.close()}})().catch(e=>{console.error(e);process.exitCode=1})`)
|
||||
check('async Node client queues requests and supports result queries within the bound Job', asynchronous.ok && asynchronous.data.stdout.includes('ASYNC_BRIDGE_PASS'))
|
||||
const operation = await sandboxBrokerOperation(asynchronous.data.runId, queryId)
|
||||
check('durable result remains queryable after disconnect with exact host-bound ownership', operation.state === 'succeeded' && operation.owner.sessionId === context.sessionId && operation.owner.runId === context.runId && operation.owner.sandboxRunId === asynchronous.data.runId)
|
||||
const journal = await fs.readFile(join(stateRoot, 'runs', asynchronous.data.runId, 'broker', `${queryId}.json`), 'utf8')
|
||||
check('operation journals retain metadata without contents or live read tokens', !journal.includes('"data"') && !journal.includes('snapshotId'))
|
||||
await assert.rejects(sandboxBrokerOperation(sync.data.runId, queryId))
|
||||
check('operation query cannot substitute an operation from another Job', true)
|
||||
const frozenId = randomUUID(), name = `case-${++caseId}.cjs`
|
||||
await fs.writeFile(join(workspace, name), load + `const c=mod.connectSync();const first=c.call('workspace.readFile',{path:'data.bin'},${JSON.stringify(frozenId)});setTimeout(()=>{try{const next=c.call('workspace.readChunk',{snapshotId:first.snapshotId,offset:16384});assert.equal(Buffer.from(next.data,'base64')[0],16384%251);c.call('workspace.closeRead',{snapshotId:first.snapshotId});console.log('FROZEN_PASS')}finally{c.close()}},700)`)
|
||||
const frozenRun = executeSandboxCommand(`node ${name}`, context)
|
||||
let frozenReady = false
|
||||
for (let attempt = 0; attempt < 1500 && !frozenReady; attempt++) {
|
||||
const runs = await fs.readdir(join(stateRoot, 'runs'))
|
||||
for (const id of runs) {
|
||||
const record = await fs.readFile(join(stateRoot, 'runs', id, 'broker', `${frozenId}.json`), 'utf8').catch(() => '')
|
||||
if (record && JSON.parse(record).state === 'succeeded') frozenReady = true
|
||||
}
|
||||
if (!frozenReady) await pause(30)
|
||||
}
|
||||
assert.ok(frozenReady); await fs.writeFile(join(workspace, 'data.bin'), Buffer.alloc(bytes.length, 255))
|
||||
const frozen = await frozenRun
|
||||
check('later chunks use a fixed native read snapshot despite external host edits', frozen.ok && frozen.data.stdout.includes('FROZEN_PASS') && frozen.data.writeback === 'unchanged')
|
||||
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
||||
const forged = '\n\x1eYCODE_WORKSPACE_READ_V1 ' + JSON.stringify({ version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.bin' }, runId: 'fake' }) + '\n'
|
||||
const broken = await run(`require('fs').writeSync(1,${JSON.stringify(forged)});setTimeout(()=>{},2000)`).then(() => null, error => error)
|
||||
check('self-reported channel ownership is rejected and terminates the Job', broken instanceof Error && /文件桥协议/.test(broken.message))
|
||||
const id = randomUUID()
|
||||
await assert.rejects(run(load + `const c=mod.connectSync();c.call('workspace.stat',{path:'data.bin'},'${id}');c.call('workspace.stat',{path:'data.bin'},'${id}')`), /文件桥协议/)
|
||||
check('request ID replay fails closed with no writeback', true)
|
||||
const rev = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: rev.revision, rules: { deniedPaths: ['data.bin'] } })
|
||||
const deniedReader = await NativeWorkspaceReader.create(binary, await effectiveSandboxPolicy(workspace))
|
||||
await assert.rejects(deniedReader.read('read', 'data.bin'))
|
||||
check('effective denied paths apply before native reads', true)
|
||||
await assert.rejects(reader.read('stat', '\u76ee\u5f55 with spaces/text.txt'), /策略已变化/)
|
||||
check('old read connections become invalid when policy revision changes', true)
|
||||
const configured = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: configured.revision, rules: { deniedPaths: [] } })
|
||||
const modelName = `case-${++caseId}.cjs`; await fs.writeFile(join(workspace, modelName), load + "const c=mod.connectSync();assert.equal(c.statSync('data.bin').kind,'file');c.close();console.log('MODEL_BRIDGE_PASS')")
|
||||
const model = await executeTool('execute_command', { command: `node ${modelName}`, runtimes: ['node'], bridge: 'workspace-read' }, { workspaceRoot: workspace, sessionId: context.sessionId })
|
||||
check('model-facing command explicitly selects the real read bridge', model.ok && model.content.includes('MODEL_BRIDGE_PASS'))
|
||||
console.log(`SANDBOX_READ_BRIDGE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, limitations: ['NTFS/local fixed volumes only; one stdio client per Job; read-only API, not per-descendant PID authentication, full fs shims, host write broker or WorkBuddy equivalence.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_READ_BRIDGE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
|
||||
import { WorkspaceReadBroker, READ_BRIDGE_PREFIX } from '../src/main/sandbox/operation-broker'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-protocol-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'data.txt'), 'pinned-workspace-data\n'.repeat(4000))
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const policy = await effectiveSandboxPolicy(workspace), reader = await NativeWorkspaceReader.create(await verifiedBinary(), policy)
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
async function harness(failReply = false) {
|
||||
const runId = randomUUID(), directory = join(stateRoot, 'runs', runId), replies: any[] = [], output: Buffer[] = []
|
||||
const identity = { sessionId: 'protocol-session', runId: 'protocol-run', toolCallId: 'protocol-call', policyRevision: policy.revision, policyFingerprint: policy.fingerprint }
|
||||
await fs.mkdir(join(directory, 'broker'), { recursive: true })
|
||||
await fs.writeFile(join(directory, 'request.json'), JSON.stringify({ protocol: 1, id: runId, workspace, identity, createdAt: Date.now() }))
|
||||
let stopped = false
|
||||
const broker = new WorkspaceReadBroker(reader, join(directory, 'broker'), { ...identity, sandboxRunId: runId }, bytes => output.push(bytes), async line => {
|
||||
if (failReply) throw new Error('simulated broken reply pipe')
|
||||
replies.push(JSON.parse(line))
|
||||
}, () => { stopped = true })
|
||||
function send(operation: string, args: Record<string, unknown>, requestId = randomUUID()): string {
|
||||
broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ version: 1, requestId, operation, args }) + '\n'))
|
||||
return requestId
|
||||
}
|
||||
async function reply(id: string): Promise<any> {
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
const response = replies.find(item => item.requestId === id)
|
||||
if (response) return response
|
||||
if (broker.failure) throw broker.failure
|
||||
await new Promise(done => setTimeout(done, 10))
|
||||
}
|
||||
throw new Error('Timed out waiting for broker response')
|
||||
}
|
||||
return { runId, directory, broker, send, reply, replies, output, stopped: () => stopped }
|
||||
}
|
||||
try {
|
||||
let h = await harness()
|
||||
h.broker.accept(Buffer.from('ordinary-output'.repeat(30_000)))
|
||||
const request = { version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.txt' } }, frame = Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify(request) + '\n')
|
||||
h.broker.accept(frame.subarray(0, 12)); h.broker.accept(frame.subarray(12))
|
||||
assert.ok((await h.reply(request.requestId)).ok)
|
||||
check('stream parser preserves long ordinary output and split control prefixes', Buffer.concat(h.output).toString() === 'ordinary-output'.repeat(30_000) + '\n')
|
||||
let snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
|
||||
const chunk = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
|
||||
check('chunk replies stay bounded while preserving one immutable native read', chunk.ok && chunk.value.sha256 === snapshot.sha256 && Buffer.from(chunk.value.data, 'base64').length === 16384)
|
||||
const fake = await h.reply(h.send('workspace.readChunk', { snapshotId: randomUUID(), offset: 0 }))
|
||||
check('unknown or cross-connection read token is denied', !fake.ok && fake.error.code === 'ENOENT')
|
||||
await h.reply(h.send('workspace.closeRead', { snapshotId: snapshot.snapshotId }))
|
||||
for (let index = 0; index < 8; index++) await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
|
||||
const tooMany = await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
|
||||
check('open immutable read snapshots have an enforced count limit', !tooMany.ok && tooMany.error.code === 'EBUSY')
|
||||
const unknown = await h.reply(h.send('network.fetch', { url: 'http://127.0.0.1' }))
|
||||
check('network and host write operations have no dispatch implementation', !unknown.ok && unknown.error.code === 'EACCES')
|
||||
await h.broker.finish(true)
|
||||
h = await harness()
|
||||
h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ ...request, runId: 'forged' }) + '\n'))
|
||||
await h.broker.finish(true)
|
||||
check('caller-supplied ownership cannot rebind the channel', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + ' '.repeat(8193))); await h.broker.finish(true)
|
||||
check('oversized request frames fail closed before JSON dispatch', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); for (let index = 0; index < 9; index++) h.send('workspace.stat', { path: 'data.txt' }); await h.broker.finish(true)
|
||||
check('request floods cannot grow the native work queue beyond eight', h.stopped() && !!h.broker.failure && h.replies.length === 0)
|
||||
h = await harness(); const replayId = h.send('workspace.stat', { path: 'data.txt' }); await h.reply(replayId); h.send('workspace.stat', { path: 'data.txt' }, replayId); await h.broker.finish(true)
|
||||
check('duplicate request IDs terminate the channel instead of repeating operations', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + '{')); await h.broker.finish(false)
|
||||
check('a truncated final control frame is a protocol failure', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX.slice(0, 12))); await h.broker.finish(false)
|
||||
check('a truncated protocol prefix cannot be silently discarded on normal exit', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); const cancelledId = h.send('workspace.readFile', { path: 'data.txt' }); await h.broker.finish(true)
|
||||
const cancelled = await sandboxBrokerOperation(h.runId, cancelledId)
|
||||
check('disconnect cancels pending work and persists cancellation without a reply', cancelled.state === 'cancelled' && h.replies.length === 0)
|
||||
h = await harness(true); const lostId = h.send('workspace.stat', { path: 'data.txt' })
|
||||
for (let attempt = 0; attempt < 300 && !h.stopped(); attempt++) await new Promise(done => setTimeout(done, 10))
|
||||
await h.broker.finish(true)
|
||||
const durable = await sandboxBrokerOperation(h.runId, lostId)
|
||||
check('lost reply after durable completion retains a queryable successful result', h.stopped() && durable.state === 'succeeded' && durable.result?.kind === 'file')
|
||||
const raw = JSON.parse(await fs.readFile(join(h.directory, 'broker', `${lostId}.json`), 'utf8'))
|
||||
raw.state = 'running'; delete raw.completedAt; delete raw.result
|
||||
await fs.writeFile(join(h.directory, 'broker', `${lostId}.json`), JSON.stringify(raw))
|
||||
check('an interrupted running record is queried as unknown rather than success', (await sandboxBrokerOperation(h.runId, lostId)).state === 'unknown')
|
||||
h = await harness(); snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
|
||||
const document = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: document.revision, rules: { commandsEnabled: false } })
|
||||
const revoked = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
|
||||
check('policy revocation also blocks bytes cached before revocation', !revoked.ok && revoked.error.code === 'EACCES')
|
||||
await h.broker.finish(true)
|
||||
console.log(`SANDBOX_READ_PROTOCOL_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Protocol fault harness with actual native workspace reader; process isolation is covered separately by read-bridge integration.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_READ_PROTOCOL_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { commitChanges, digest, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { releaseRunLease } from '../src/main/sandbox/maintenance'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Native sandbox regression must run on Windows; unsupported hosts must not report a pass')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-regression-'))
|
||||
const workspace = join(fixture, 'workspace with spaces'), outside = join(fixture, 'outside.txt')
|
||||
await fs.mkdir(workspace)
|
||||
await fs.writeFile(outside, 'outside-native-fixture')
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
await fs.writeFile(join(workspace, '.env'), 'DUMMY_SECRET=not-a-real-secret')
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
|
||||
const stateRoot = join(fixture, 'state'), resourceDirectory = resolve('resources/sandbox/win32-x64')
|
||||
configureSandbox({ stateRoot, resourceDirectory })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void {
|
||||
checks.push({ label, pass: !!condition })
|
||||
assert.ok(condition, label)
|
||||
console.log(`PASS ${label}`)
|
||||
}
|
||||
const context = { workspaceRoot: workspace, runId: 'test-run', toolCallId: 'test-tool', approve: async () => true }
|
||||
try {
|
||||
check('native binary and manifest identity are installed', (await sandboxStatus()).state === 'installed')
|
||||
process.env.YCODE_FAKE_SECRET = 'dummy-sensitive-fixture'
|
||||
let result = await executeSandboxCommand('sandbox-probe.exe --info', context)
|
||||
const initialAuditRunId = result.data.runId
|
||||
check('real child is an AppContainer in a Job and has no inherited dummy credential', result.ok && result.data.stdout.includes('"appContainer":true,"job":true,"secretInherited":false'))
|
||||
check('read-only command needs no writeback and native profile is removed', result.data.writeback === 'unchanged' && result.data.profileRemoved)
|
||||
check('temporary drive mapping is removed on normal completion', result.data.driveRemoved)
|
||||
const cleanup = JSON.parse(await fs.readFile(join(stateRoot, 'runs', result.data.runId, 'cleanup.json'), 'utf8'))
|
||||
check('completed scratch copy is removed while audit remains', cleanup.stageRemoved && !(await fs.stat(join(stateRoot, 'runs', result.data.runId, 'stage')).catch(() => null)))
|
||||
result = await executeSandboxCommand('dir /b', context)
|
||||
check('cmd directory enumeration works in mapped scratch drive', result.ok && result.data.stdout.includes('sample.txt'))
|
||||
result = await executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Get-Content sample.txt; Get-ChildItem -Name"', context)
|
||||
check('PowerShell can read and enumerate the scratch workspace', result.ok && result.data.stdout.includes('before') && result.data.stdout.includes('sample.txt'))
|
||||
delete process.env.YCODE_FAKE_SECRET
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --read "${outside}"`, context)
|
||||
check('native process cannot read an ordinary host file outside the snapshot', !result.ok && result.data.stdout.includes('READ_DENIED'))
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --write "${outside}"`, context)
|
||||
check('native process cannot overwrite outside file', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
|
||||
check('outside file remains byte-identical', await fs.readFile(outside, 'utf8') === 'outside-native-fixture')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --spawn-write "${outside}"`, context)
|
||||
check('descendant process inherits isolation', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --breakaway escaped.txt', context)
|
||||
check('Job denies explicit process breakaway', !result.ok && result.data.stdout.includes('SPAWN_DENIED'))
|
||||
result = await executeSandboxCommand('type .env', context)
|
||||
check('dummy credential file is excluded from command snapshot', !result.ok && !result.data.stdout.includes('DUMMY_SECRET'))
|
||||
let approvals = 0
|
||||
result = await executeSandboxCommand('echo approved>sample.txt', { ...context, approve: async () => { approvals++; return true } })
|
||||
check('workspace changes are written back after exactly one approval', result.ok && approvals === 1 && result.data.writeback === 'committed')
|
||||
check('before/after identity and independent transaction are returned', result.data.changes[0]?.operation === 'update' && !!result.data.transactionId)
|
||||
check('approved native write reaches actual workspace', (await fs.readFile(join(workspace, 'sample.txt'), 'utf8')).trim() === 'approved')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('independent backup restores exact bytes and recovery is idempotent', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
result = await executeSandboxCommand('echo rejected>sample.txt', { ...context, approve: async () => false })
|
||||
check('rejected writeback reports failure without host mutation', !result.ok && result.data.writeback === 'rejected' && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const conflict = await executeSandboxCommand('echo pending>sample.txt', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-change'); return true } }).then(() => null, error => error as Error)
|
||||
check('approval-time external edit causes a conflict', conflict instanceof Error)
|
||||
check('conflict does not overwrite external changes', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-change')
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
result = await executeSandboxCommand('del sample.txt', context)
|
||||
check('delete is an approved backed-up transaction', result.ok && result.data.changes[0]?.operation === 'delete' && !(await fs.stat(join(workspace, 'sample.txt')).catch(() => null)))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('deleted file restores from independent backup', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
result = await executeSandboxCommand('echo new>created.txt', context)
|
||||
check('new file is recorded as create', result.ok && result.data.changes.some(change => change.operation === 'create'))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('restoring creation removes only the unchanged created file', !(await fs.stat(join(workspace, 'created.txt')).catch(() => null)))
|
||||
result = await executeSandboxCommand('echo uncommitted>sample.txt & exit /b 7', context)
|
||||
check('unsuccessful command never writes back', !result.ok && result.data.exitCode === 7 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const cancelled = new AbortController()
|
||||
const pending = executeSandboxCommand('sandbox-probe.exe --delay 4000 late.txt', { ...context, signal: cancelled.signal })
|
||||
setTimeout(() => cancelled.abort(), 700)
|
||||
result = await pending
|
||||
check('cancellation terminates native command and removes profile', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved)
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --delay 4000 timed-out.txt', { ...context, timeoutMs: 300 })
|
||||
check('native timeout is enforced', !result.ok && result.data.status === 'timeout' && result.data.profileRemoved)
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --spawn-delayed descendant-late.txt', context)
|
||||
await new Promise(done => setTimeout(done, 1600))
|
||||
const descendantPath = join(stateRoot, 'runs', result.data.runId, 'stage/workspace/descendant-late.txt')
|
||||
check('background descendants are terminated when the root command exits', !(await fs.stat(descendantPath).catch(() => null)))
|
||||
const orphanId = randomUUID(), orphanDirectory = join(stateRoot, 'runs', orphanId), orphanStage = join(orphanDirectory, 'stage')
|
||||
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(orphanStage, part), { recursive: true })
|
||||
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(orphanStage, 'workspace/sandbox-probe.exe'))
|
||||
await fs.writeFile(join(orphanStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 crash-late.txt\r\n')
|
||||
const crashed = spawn(join(resourceDirectory, 'ycode-sandbox.exe'), ['--run', `YCode.Run.${orphanId.replaceAll('-', '')}`, orphanStage, join(orphanDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] })
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
check('native leases are persisted outside the child writable area', !!await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null))
|
||||
const closed = new Promise(done => crashed.once('close', done))
|
||||
crashed.kill(); await closed
|
||||
check('maintenance releases crash-orphaned AppContainer and exact drive mapping', await releaseRunLease(join(resourceDirectory, 'ycode-sandbox.exe'), orphanDirectory) === 'released')
|
||||
const parentId = randomUUID(), parentDirectory = join(stateRoot, 'runs', parentId), parentStage = join(parentDirectory, 'stage')
|
||||
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(parentStage, part), { recursive: true })
|
||||
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(parentStage, 'workspace/sandbox-probe.exe'))
|
||||
await fs.writeFile(join(parentStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 parent-late.txt\r\n')
|
||||
const parentScript = join(fixture, 'parent-exit.cjs')
|
||||
await fs.writeFile(parentScript, `const fs=require('node:fs');const cp=require('node:child_process');const a=process.argv.slice(2);cp.spawn(a[0],a.slice(1),{windowsHide:true,detached:true,stdio:'ignore'});const t=setInterval(()=>{if(fs.existsSync(${JSON.stringify(join(parentDirectory, 'lease.json'))})){clearInterval(t);setTimeout(()=>process.exit(0),250)}},20);setTimeout(()=>process.exit(2),5000).unref();`)
|
||||
const shortParent = spawn(process.execPath, [parentScript, join(resourceDirectory, 'ycode-sandbox.exe'), '--run', `YCode.Run.${parentId.replaceAll('-', '')}`, parentStage, join(parentDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: 'ignore' })
|
||||
await new Promise(done => shortParent.once('close', done))
|
||||
for (let attempt = 0; attempt < 250 && !await fs.stat(join(parentDirectory, 'result.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
const parentReport = JSON.parse(await fs.readFile(join(parentDirectory, 'result.json'), 'utf8'))
|
||||
check('launch-parent exit terminates the Job and cleans native resources', parentReport.status === 'parent-exited' && parentReport.profileRemoved && parentReport.driveRemoved)
|
||||
let connections = 0
|
||||
const server = createServer(socket => { connections++; socket.end('local-test') })
|
||||
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
||||
try {
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, { ...context, timeoutMs: 4000 })
|
||||
check('real local TCP connection is denied by capability-free AppContainer', !result.ok && result.data.stdout.includes('TCP_DENIED'))
|
||||
check('local TCP listener observes no sandbox connection', connections === 0)
|
||||
} finally { await new Promise<void>(done => server.close(() => done())) }
|
||||
const defaultContext = { ...context, permissionMode: 'default-sandbox' as const }
|
||||
let tool = await executeTool('read_file', { path: '.env' }, defaultContext)
|
||||
check('host file tool shares the sensitive-file policy', !tool.ok)
|
||||
tool = await executeTool('write_file', { path: '.env', content: 'blocked' }, defaultContext)
|
||||
check('host writes cannot replace a protected credential file', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
|
||||
tool = await executeTool('read_file', { path: outside }, defaultContext)
|
||||
check('host file tool rejects outside reads', !tool.ok)
|
||||
await fs.symlink(join(fixture), join(workspace, 'outside-link'), 'junction')
|
||||
tool = await executeTool('read_file', { path: 'outside-link/outside.txt' }, defaultContext)
|
||||
check('host file tool rejects junction escape', !tool.ok)
|
||||
tool = await executeTool('write_file', { path: 'outside-link/outside.txt', content: 'explicit-full-access' }, { ...defaultContext, permissionMode: 'full-access' })
|
||||
check('explicit full access preserves existing link-target write behavior', tool.ok && (tool.data as { path: string }).path === outside && await fs.readFile(outside, 'utf8') === 'explicit-full-access')
|
||||
await fs.writeFile(outside, 'outside-native-fixture')
|
||||
await fs.link(outside, join(workspace, 'outside-hardlink.txt'))
|
||||
tool = await executeTool('read_file', { path: 'outside-hardlink.txt' }, defaultContext)
|
||||
check('host file tool rejects multi-link alias', !tool.ok)
|
||||
await fs.unlink(join(workspace, 'outside-hardlink.txt'))
|
||||
await fs.unlink(join(workspace, 'outside-link'))
|
||||
tool = await executeTool('write_file', { path: 'host.txt', content: 'protected-write' }, defaultContext)
|
||||
const data = tool.data as { transactionId?: string }
|
||||
check('host write records a persistent independent transaction', tool.ok && !!data.transactionId)
|
||||
await restoreTransaction(data.transactionId!, workspace)
|
||||
check('host write backup supports recovery', !(await fs.stat(join(workspace, 'host.txt')).catch(() => null)))
|
||||
const signal = new AbortController()
|
||||
tool = await executeTool('write_file', { path: 'cancelled-host.txt', content: 'blocked' }, { ...defaultContext, signal: signal.signal, approve: async () => { signal.abort(); return true } })
|
||||
check('cancellation during host approval does not write', !tool.ok && !(await fs.stat(join(workspace, 'cancelled-host.txt')).catch(() => null)))
|
||||
tool = await executeTool('execute_command', { command: 'echo host-route' }, defaultContext)
|
||||
check('actual default host command uses native sandbox route', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer')
|
||||
tool = await executeTool('execute_command', { command: 'mkdir empty-only' }, defaultContext)
|
||||
check('empty-directory writeback uses an approved directory transaction', tool.ok && (await fs.stat(join(workspace, 'empty-only'))).isDirectory())
|
||||
await restoreTransaction((tool.data as { transactionId: string }).transactionId, workspace)
|
||||
tool = await executeTool('execute_command', { command: 'echo bypass>.env' }, defaultContext)
|
||||
check('command-generated protected file never reaches the host', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
|
||||
result = await executeSandboxCommand('echo unicode>unicode-\u4e2d\u6587.txt', context)
|
||||
check('UTF-8 script and Unicode file writeback work', result.ok && (await fs.readFile(join(workspace, 'unicode-\u4e2d\u6587.txt'), 'utf8')).trim() === 'unicode')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
result = await executeSandboxCommand('echo committed>audit-fault.txt', { ...context, toolCallId: 'audit-fault', approve: async () => {
|
||||
for (const entry of await fs.readdir(join(stateRoot, 'runs'))) {
|
||||
const record = await fs.readFile(join(stateRoot, 'runs', entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
|
||||
if (record?.identity?.toolCallId === 'audit-fault') await fs.mkdir(join(stateRoot, 'runs', entry, 'audit.json'))
|
||||
}
|
||||
return true
|
||||
} })
|
||||
check('audit storage failure preserves the actual committed outcome', result.ok && result.data.writeback === 'committed' && !result.data.auditSaved && (await fs.readFile(join(workspace, 'audit-fault.txt'), 'utf8')).trim() === 'committed')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const transaction = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context)
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'new-external-change')
|
||||
await assert.rejects(restoreTransaction(transaction, workspace), /外部修改/)
|
||||
check('recovery refuses to overwrite subsequent external edit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'new-external-change')
|
||||
configureSandbox({ stateRoot, resourceDirectory: join(fixture, 'missing-backend') })
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
|
||||
check('missing native backend fails closed without shell fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
|
||||
const tampered = join(fixture, 'tampered-backend')
|
||||
await fs.mkdir(tampered)
|
||||
await fs.copyFile(join(resourceDirectory, 'ycode-sandbox.exe'), join(tampered, 'ycode-sandbox.exe'))
|
||||
const badManifest = JSON.parse(await fs.readFile(join(resourceDirectory, 'manifest.json'), 'utf8')); badManifest.binarySha256 = '0'.repeat(64)
|
||||
await fs.writeFile(join(tampered, 'manifest.json'), JSON.stringify(badManifest))
|
||||
configureSandbox({ stateRoot, resourceDirectory: tampered })
|
||||
check('manifest mismatch marks the backend unavailable', (await sandboxStatus()).state === 'unavailable')
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
|
||||
check('manifest mismatch cannot enable a host fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
|
||||
configureSandbox({ stateRoot, resourceDirectory })
|
||||
const audit = JSON.parse(await fs.readFile(join(stateRoot, 'runs', initialAuditRunId, 'audit.json'), 'utf8'))
|
||||
check('audit persists identity and excludes raw stdout/stderr', audit.identity.runId === 'test-run' && !Object.hasOwn(audit, 'stdout') && !Object.hasOwn(audit, 'stderr'))
|
||||
console.log(`SANDBOX_REGRESSION_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
delete process.env.YCODE_FAKE_SECRET
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Windows AppContainer boundary and YCode host tools; not WorkBuddy equivalence, arbitrary native hooks, registry parity or all possible network protocols.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer, createConnection } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Bound stdio probe requires actual Windows processes')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-stdio-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace); await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const context = { workspaceRoot: workspace, runId: 'bound-host-run', toolCallId: 'bound-host-call', stdioProbe: true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
let result = await executeSandboxCommand(`sandbox-probe.exe --broker-echo ${randomUUID()}`, context)
|
||||
check('real capability-free AppContainer exchanges a bounded reply over its inherited stdio', result.ok && result.data.stdout.includes('BROKER_ECHO_RECEIVED') && result.data.brokerTransport === 'bound-stdio-v1')
|
||||
check('control frames are not reinterpreted as ordinary model output', !result.data.stdout.includes('YCODE_BROKER_V1') && result.data.writeback === 'unchanged')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --broker-spoof ${randomUUID()}`, context)
|
||||
check('child-supplied run identity is denied instead of rebinding the request', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED'))
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --broker-deny ${randomUUID()}`, context)
|
||||
check('experimental transport cannot perform host filesystem or network operations', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED') && result.data.writeback === 'unchanged')
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-replay ${randomUUID()}`, context), /代理协议/)
|
||||
check('replayed request IDs cancel the experiment without writeback', true)
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-oversize ${randomUUID()}`, context), /代理协议/)
|
||||
check('oversized frames fail closed', true)
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-flood ${randomUUID()}`, context), /代理协议/)
|
||||
check('excess concurrent requests are bounded and rejected', true)
|
||||
let accepted = 0
|
||||
const server = createServer(socket => { accepted++; socket.destroy() })
|
||||
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
||||
try {
|
||||
const address = server.address(); assert.ok(address && typeof address === 'object')
|
||||
await new Promise<void>((done, reject) => { const client = createConnection(address.port, '127.0.0.1'); client.once('connect', () => { client.destroy(); done() }); client.once('error', reject) })
|
||||
const before = accepted
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, context)
|
||||
check('enabling the inherited transport leaves actual network egress denied', !result.ok && result.data.stdout.includes('TCP_DENIED') && accepted === before && before > 0)
|
||||
} finally { await new Promise<void>(done => server.close(() => done())) }
|
||||
const abort = new AbortController()
|
||||
const pending = executeSandboxCommand('sandbox-probe.exe --delay 5000 late.txt', { ...context, signal: abort.signal })
|
||||
setTimeout(() => abort.abort(), 500)
|
||||
result = await pending
|
||||
check('cancellation terminates the stdio-enabled Job and cleans its resources', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved && result.data.driveRemoved && !await fs.stat(join(workspace, 'late.txt')).catch(() => null))
|
||||
const tool = await executeTool('execute_command', { command: `sandbox-probe.exe --broker-echo ${randomUUID()}`, stdioProbe: true }, { workspaceRoot: workspace })
|
||||
check('model arguments cannot enable the experimental broker transport', !tool.ok && !(tool.data as { brokerTransport?: string })?.brokerTransport)
|
||||
console.log(`SANDBOX_STDIO_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Bound inherited channel and echo-only dispatch; not a per-descendant PID-authenticated broker, filesystem broker, network proxy or WorkBuddy equivalence. Model-facing tools cannot enable this experiment.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_STDIO_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Toolchain regression requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-toolchain-'))
|
||||
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state'), outside = join(fixture, 'outside.txt')
|
||||
await fs.mkdir(workspace); await fs.writeFile(outside, 'host-only-fixture')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, sessionId: 'toolchain-session', runId: 'toolchain-run', toolCallId: 'toolchain-call', approve: async () => true }
|
||||
try {
|
||||
check('Node/Python start missing instead of inheriting host PATH', (await listSandboxToolchains()).filter(item => item.kind !== 'git').every(item => item.state === 'missing'))
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /尚未注册/)
|
||||
check('requesting a missing runtime fails closed', true)
|
||||
await assert.rejects(importSandboxToolchain('node', 'relative-node.exe'), /绝对路径/)
|
||||
const node = await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
check('explicit Node import persists content-addressed file identities and npm', node.state === 'registered' && !!node.id && !!node.files && node.features!.includes('npm'))
|
||||
process.env.YCODE_DUMMY_MODEL_TOKEN = 'not-a-real-credential'
|
||||
let result = await executeSandboxCommand('node -e "console.log(process.version); console.log(process.env.YCODE_DUMMY_MODEL_TOKEN || \'NO_TOKEN\'); console.log(process.env.NPM_CONFIG_OFFLINE)"', { ...context, runtimes: ['node'] })
|
||||
check('real Node executes inside the native sandbox with a clean environment', result.ok && result.data.stdout.includes(process.version) && result.data.stdout.includes('NO_TOKEN') && !result.data.stdout.includes('not-a-real-credential'))
|
||||
check('native execution returns the exact staged runtime identity', result.data.toolchains?.[0]?.id === node.id)
|
||||
await fs.writeFile(join(workspace, 'host-read.cjs'), `try { require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9) } catch(error) { if(!['EACCES','EPERM'].includes(error.code))throw error; console.log('HOST_READ_DENIED') }`)
|
||||
result = await executeSandboxCommand('node host-read.cjs', { ...context, runtimes: ['node'] })
|
||||
check('real Node cannot read the ordinary host fixture', result.ok && result.data.stdout.includes('HOST_READ_DENIED'))
|
||||
await fs.writeFile(join(workspace, 'runtime-ro.cjs'), "const fs=require('fs'),path=require('path');const target=path.join(path.dirname(process.execPath),'node_modules/npm/package.json');fs.readFileSync(target);try {fs.appendFileSync(target,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('RUNTIME_READONLY')}")
|
||||
result = await executeSandboxCommand('node runtime-ro.cjs', { ...context, runtimes: ['node'] })
|
||||
check('native ACLs prevent modification of the staged interpreter', result.ok && result.data.stdout.includes('RUNTIME_READONLY'))
|
||||
await fs.writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'ycode-sandbox-fixture', version: '1.0.0', private: true, scripts: { build: 'node build.cjs', test: 'node test.cjs' } }))
|
||||
await fs.writeFile(join(workspace, 'build.cjs'), "require('fs').writeFileSync('artifact.txt','built-in-sandbox')")
|
||||
await fs.writeFile(join(workspace, 'test.cjs'), "require('assert').equal(require('fs').readFileSync('artifact.txt','utf8'),'built-in-sandbox');console.log('NODE_PROJECT_PASS')")
|
||||
result = await executeSandboxCommand('npm run build && npm test', { ...context, runtimes: ['node'] })
|
||||
check('npm builds and tests a real offline project in the snapshot', result.ok && result.data.stdout.includes('NODE_PROJECT_PASS') && result.data.writeback === 'committed')
|
||||
check('npm build artifact reaches the host only through approved transaction', await fs.readFile(join(workspace, 'artifact.txt'), 'utf8') === 'built-in-sandbox' && !!result.data.transactionId)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
|
||||
const python = await importSandboxToolchain('python', await fs.realpath(pythonSource))
|
||||
check('Python standard library and pip are imported without executing source on the host', python.state === 'registered' && python.features!.includes('pip'))
|
||||
result = await executeSandboxCommand('python -c "import sys,json,ssl,sqlite3; print(\'PYTHON_STDLIB_PASS\'); print(sys.prefix)"', { ...context, runtimes: ['python'] })
|
||||
check('real Python loads its private standard library and extension modules', result.ok && result.data.stdout.includes('PYTHON_STDLIB_PASS') && !result.data.stdout.includes(pythonSource))
|
||||
result = await executeSandboxCommand('python -m pip --version', { ...context, runtimes: ['python'] })
|
||||
check('pip runs from the controlled Python runtime', result.ok && /pip \d/.test(result.data.stdout))
|
||||
await fs.writeFile(join(workspace, 'test_fixture.py'), "from pathlib import Path\nimport unittest\nclass Fixture(unittest.TestCase):\n def test_write(self):\n Path('python-artifact.txt').write_text('python-built',encoding='utf-8')\n self.assertEqual(Path('python-artifact.txt').read_text(), 'python-built')\n")
|
||||
result = await executeSandboxCommand('python -m unittest test_fixture', { ...context, runtimes: ['python'] })
|
||||
check('Python tests execute and write approved artifacts', result.ok && result.data.writeback === 'committed' && await fs.readFile(join(workspace, 'python-artifact.txt'), 'utf8') === 'python-built')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('runtime status exposes separate registered Node and Python capabilities', (await sandboxStatus()).toolchains?.filter(item => item.kind !== 'git').every(item => item.state === 'registered'))
|
||||
await fs.mkdir(join(workspace, 'node_modules/fixture-package'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/package.json'), JSON.stringify({ name: 'fixture-package', version: '1.0.0', exports: { require: './index.cjs', import: './esm.mjs' } }))
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/index.cjs'), "module.exports={value:'DEPENDENCY_CJS_PASS'}")
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/esm.mjs'), "export const value='DEPENDENCY_ESM_PASS'")
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/.env'), 'DUMMY_PACKAGE_CREDENTIAL')
|
||||
const nodeDependencies = await importSandboxDependencies(workspace, 'node')
|
||||
check('dependency import binds workspace lock inputs and runtime identity while filtering credentials', nodeDependencies.excluded === 1 && nodeDependencies.identity.runtimeId === node.id && nodeDependencies.files === 3)
|
||||
result = await executeSandboxCommand('node -e "console.log(require(\'fixture-package\').value)" && node --input-type=module -e "import {value} from \'fixture-package\';console.log(value)"', { ...context, runtimes: ['node'], dependencies: ['node'] })
|
||||
check('controlled node_modules supports real CommonJS and ESM resolution', result.ok && result.data.stdout.includes('DEPENDENCY_CJS_PASS') && result.data.stdout.includes('DEPENDENCY_ESM_PASS') && result.data.writeback === 'unchanged')
|
||||
check('execution records the exact workspace dependency layer identity', result.data.dependencies?.[0]?.id === nodeDependencies.identity.id)
|
||||
await fs.writeFile(join(workspace, 'dep-ro.cjs'), "const fs=require('fs'),p=require.resolve('fixture-package');fs.readFileSync(p);try{fs.appendFileSync(p,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('DEPENDENCY_READONLY')}")
|
||||
result = await executeSandboxCommand('node dep-ro.cjs', { ...context, runtimes: ['node'], dependencies: ['node'] })
|
||||
check('native ACLs enforce dependency-layer immutability', result.ok && result.data.stdout.includes('DEPENDENCY_READONLY'))
|
||||
await fs.writeFile(join(workspace, 'package-lock.json'), '{}')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /锁文件已变化/)
|
||||
check('changed project lock inputs invalidate a cached dependency layer', true)
|
||||
await fs.unlink(join(workspace, 'package-lock.json'))
|
||||
const anotherWorkspace = join(fixture, 'another-workspace'); await fs.mkdir(anotherWorkspace)
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, workspaceRoot: anotherWorkspace, runtimes: ['node'], dependencies: ['node'] }), /ENOENT/)
|
||||
check('one workspace cannot accidentally select another workspace dependency cache', true)
|
||||
await fs.mkdir(join(workspace, '.venv/Lib/site-packages/fixture_module'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, '.venv/Lib/site-packages/fixture_module/__init__.py'), "value='PYTHON_DEPENDENCY_PASS'\n")
|
||||
const pythonDependencies = await importSandboxDependencies(workspace, 'python')
|
||||
result = await executeSandboxCommand('python -c "import fixture_module;print(fixture_module.value)"', { ...context, runtimes: ['python'], dependencies: ['python'] })
|
||||
check('Python imports a workspace-scoped controlled site-packages layer', result.ok && result.data.stdout.includes('PYTHON_DEPENDENCY_PASS') && result.data.dependencies?.[0]?.id === pythonDependencies.identity.id)
|
||||
const cacheKey = (await fs.readdir(join(stateRoot, 'dependencies')))[0]
|
||||
const cacheFile = join(stateRoot, 'dependencies', cacheKey, 'objects', nodeDependencies.identity.id, 'files/fixture-package/index.cjs')
|
||||
const cacheBytes = await fs.readFile(cacheFile)
|
||||
await fs.appendFile(cacheFile, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /依赖文件摘要不匹配/)
|
||||
check('a tampered dependency layer fails closed before execution', true)
|
||||
await fs.writeFile(cacheFile, cacheBytes)
|
||||
const alias = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/node.json'), 'utf8'))
|
||||
const executable = join(stateRoot, 'toolchains/objects', alias.id, 'files/node.exe')
|
||||
const bytes = await fs.readFile(executable)
|
||||
await fs.appendFile(executable, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /摘要不匹配/)
|
||||
check('a tampered registered interpreter is rejected before native execution', true)
|
||||
await fs.writeFile(executable, bytes)
|
||||
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['node', 'node'] }), /选择无效/)
|
||||
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['../host'] }), /只支持/)
|
||||
check('runtime IDs cannot be duplicated or used as arbitrary source paths', true)
|
||||
console.log(`SANDBOX_TOOLCHAIN_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
delete process.env.YCODE_DUMMY_MODEL_TOKEN
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['This suite covers Node/npm, Python/pip and immutable dependency layers; private Git repository coverage is in test:sandbox:git. Host Git metadata integration, network installs, persistent mutable workspace, pnpm link layouts and full native-addon compatibility remain pending.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_TOOLCHAIN_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { workspaceKey, workspaceRecord, type WorkspaceRevision } from '../src/main/sandbox/workspace'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { executeConversationTool } from '../src/main/agent/conversation-tools'
|
||||
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
|
||||
if (process.argv[2] === '--worker') {
|
||||
const [, , , childWorkspace, childState, childSession] = process.argv
|
||||
configureSandbox({ stateRoot: childState, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await executeSandboxCommand('git branch lost-on-crash && echo interrupted>crash.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 25"',
|
||||
{ workspaceRoot: childWorkspace, sessionId: childSession, workspaceMode: 'persistent', runtimes: ['git'] })
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Persistent workspace requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-workspace-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'file.txt'), 'host-before')
|
||||
for (const generated of ['out', 'dist', 'release']) {
|
||||
await fs.mkdir(join(workspace, generated), { recursive: true })
|
||||
await fs.writeFile(join(workspace, generated, 'generated.bin'), 'generated-output')
|
||||
}
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } }
|
||||
const run = (command: string, extra: Partial<Parameters<typeof executeSandboxCommand>[1]> = {}) => executeSandboxCommand(command, { ...context, workspaceMode: 'persistent', ...extra })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const root = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId))
|
||||
async function revision(): Promise<WorkspaceRevision> {
|
||||
const active = await workspaceRecord<{ id: string }>(join(root, 'active.json'))
|
||||
return workspaceRecord(join(root, 'revisions', `${active.id}.json`))
|
||||
}
|
||||
try {
|
||||
let result = await run('echo draft-value>file.txt')
|
||||
check('successful command saves a draft without host writes or approval', result.ok && result.data.writeback === 'deferred' && approvals === 0 && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-before')
|
||||
const first = await revision()
|
||||
check('generated build output stays outside the retained draft', !first.tree.some(entry => /^(out|dist|release)(\/|$)/.test(entry.path)) && first.excluded >= 3)
|
||||
result = await run('type file.txt')
|
||||
const second = await revision()
|
||||
check('next real native command reads the retained draft', result.ok && result.data.stdout.includes('draft-value') && result.data.workspace?.changes.length === 1)
|
||||
check('normal command boundaries preserve the exact physical worktree identity', first.physicalIdentity === second.physicalIdentity && first.id !== second.id)
|
||||
const outcome = await sandboxOperation(result.data.runId)
|
||||
check('durable run outcome distinguishes a deferred draft from host commit', outcome.writeback === 'deferred' && outcome.workspace?.sessionId === context.sessionId && outcome.workspace.revision === second.id)
|
||||
let status = await workspaceAction('changes', context)
|
||||
check('change inspection reports the exact pending host change', status.state === 'ready' && status.changes.length === 1 && status.changes[0].path === 'file.txt')
|
||||
let committed = await workspaceAction('commit', context, status.revision)
|
||||
check('explicit commit consumes one approval and uses a native host transaction', committed.writeback === 'committed' && !!committed.transactionId && approvals === 1 && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).trim() === 'draft-value')
|
||||
check('commit advances the host baseline and clears pending changes', committed.changes.length === 0 && committed.lastTransactionId === committed.transactionId)
|
||||
result = await run('type file.txt')
|
||||
check('read-only command after commit leaves the draft clean', result.ok && result.data.writeback === 'unchanged' && approvals === 1)
|
||||
await run('echo pending>pending.txt')
|
||||
committed = await workspaceAction('commit', { ...context, approve: async () => false })
|
||||
check('rejected commit keeps draft bytes and leaves host untouched', committed.writeback === 'rejected' && committed.changes.some(change => change.path === 'pending.txt') && !await fs.stat(join(workspace, 'pending.txt')).catch(() => null))
|
||||
status = await workspaceAction('discard', context)
|
||||
result = await run('if exist pending.txt (exit /b 9) else (type file.txt)')
|
||||
check('discard archives the draft and rebuilds from the current host', status.state === 'ready' && status.changes.length === 0 && result.ok && result.data.stdout.includes('draft-value') && (await fs.readdir(join(root, 'archives'))).some(name => name.startsWith('discarded-')))
|
||||
await run('echo private-again>file.txt')
|
||||
await fs.writeFile(join(workspace, 'external.txt'), 'external-clean')
|
||||
result = await run('type file.txt && type external.txt')
|
||||
check('three-way synchronization imports unrelated host edits without losing draft changes', result.ok && result.data.stdout.includes('private-again') && result.data.stdout.includes('external-clean') && result.data.workspace?.changes.length === 1)
|
||||
await fs.writeFile(join(workspace, 'file.txt'), 'host-conflict')
|
||||
await assert.rejects(run('echo must-not-run>blocked.txt'), /编辑冲突/)
|
||||
status = await workspaceAction('changes', context)
|
||||
check('same-file host and draft edits block execution and commit with explicit conflicts', status.state === 'conflict' && status.conflicts?.includes('file.txt') && !await fs.stat(join(workspace, 'blocked.txt')).catch(() => null) && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-conflict')
|
||||
await workspaceAction('discard', context)
|
||||
await run('echo saved-before-failure>checkpoint.txt')
|
||||
result = await run('echo unsuccessful>checkpoint.txt && echo unsuccessful>failed.txt && exit /b 7')
|
||||
check('failed commands do not publish their private filesystem changes', !result.ok && result.data.exitCode === 7 && !await fs.stat(join(workspace, 'failed.txt')).catch(() => null))
|
||||
result = await run('type checkpoint.txt && if exist failed.txt exit /b 9')
|
||||
check('failed command rollback restores the last durable draft checkpoint', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
const controller = new AbortController(), cancel = setTimeout(() => controller.abort(), 1100)
|
||||
try { result = await run('echo cancelled>checkpoint.txt && echo CANCELLATION_MUTATED && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 12"', { signal: controller.signal }) }
|
||||
finally { clearTimeout(cancel) }
|
||||
check('cancelled command returns native cancellation and retains a draft view', !result.ok && result.data.status === 'cancelled' && result.data.workspace?.state === 'ready')
|
||||
result = await run('type checkpoint.txt')
|
||||
check('cancellation restores the last saved private bytes', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
result = await run('echo timed-out>checkpoint.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', { timeoutMs: 1000 })
|
||||
check('native timeout discards private changes without writing the host', !result.ok && result.data.status === 'timeout' && !await fs.stat(join(workspace, 'checkpoint.txt')).catch(() => null))
|
||||
await assert.rejects(run('echo forbidden>.env'), /受保护|链接/)
|
||||
result = await run('if exist .env (exit /b 9) else (type checkpoint.txt)')
|
||||
check('protected private outputs reject the whole draft candidate and roll back', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
const other = await run('if exist checkpoint.txt (exit /b 9) else (echo OTHER_SESSION_CLEAN)', { sessionId: 'other-session' })
|
||||
check('another session starts from the host and cannot inherit pending private files', other.ok && other.data.stdout.includes('OTHER_SESSION_CLEAN'))
|
||||
const privateRead = await executeSandboxCommand(`type "${join(root, 'worktree/checkpoint.txt')}"`, { ...context, sessionId: 'other-session' })
|
||||
check('a different AppContainer cannot read the sealed retained tree by absolute path', !privateRead.ok && !privateRead.data.stdout.includes('saved-before-failure'))
|
||||
const current = await revision(), privateBlob = current.tree.find(entry => entry.path === 'checkpoint.txt')!
|
||||
result = await run(`type "${join(root, 'blobs', privateBlob.hash!)}"`)
|
||||
check('even a new owner-session Job cannot directly read host-only checkpoint blobs', !result.ok && !result.data.stdout.includes('saved-before-failure'))
|
||||
await fs.rename(join(root, 'worktree'), join(root, 'replaced-physical'))
|
||||
await fs.cp(join(root, 'replaced-physical'), join(root, 'worktree'), { recursive: true, force: false, errorOnExist: true })
|
||||
await assert.rejects(run('echo must-not-start>root-replaced.txt'), /物理目录身份/)
|
||||
await workspaceAction('recover', context)
|
||||
result = await run('type checkpoint.txt')
|
||||
check('physical tree replacement is rejected and explicit recovery restores a verified checkpoint', result.ok && result.data.stdout.includes('saved-before-failure') && !await fs.stat(join(workspace, 'root-replaced.txt')).catch(() => null))
|
||||
let tool = await executeTool('execute_command', { command: 'echo tool-route>tool.txt', workspace: 'persistent' }, context)
|
||||
check('model-facing command selects the real persistent backend', tool.ok && (tool.data as { writeback?: string }).writeback === 'deferred' && !await fs.stat(join(workspace, 'tool.txt')).catch(() => null))
|
||||
tool = await executeTool('execute_command', { command: 'echo default-tool-route>default-tool.txt' }, context)
|
||||
check('model-facing commands default to the retained draft instead of copying on every command', tool.ok
|
||||
&& (tool.data as { writeback?: string; workspace?: { mode?: string } }).writeback === 'deferred'
|
||||
&& (tool.data as { workspace?: { mode?: string } }).workspace?.mode === 'persistent'
|
||||
&& !await fs.stat(join(workspace, 'default-tool.txt')).catch(() => null))
|
||||
tool = await executeTool('sandbox_workspace', { action: 'changes' }, context)
|
||||
check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt')
|
||||
&& (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'default-tool.txt'))
|
||||
tool = await executeTool('sandbox_workspace', { action: 'status', sessionId: 'other-session', workspaceRoot: stateRoot }, context)
|
||||
check('workspace tool rejects caller-supplied ownership and path overrides', !tool.ok && /归属参数/.test(tool.content))
|
||||
tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' })
|
||||
check('explicit persistent mode keeps native confinement even with full-access host context', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer' && !await fs.stat(join(workspace, 'explicit-mode.txt')).catch(() => null))
|
||||
const deniedMode = await executeConversationTool('sandbox_workspace', { action: 'commit' }, { ...context, agentMode: 'plan', signal: new AbortController().signal, emit: () => undefined })
|
||||
check('planning mode cannot invoke workspace commit or recovery operations', !deniedMode.ok && /模式不允许/.test(deniedMode.content))
|
||||
await assert.rejects(workspaceAction('discard', context, first.id), /版本已变化/)
|
||||
check('stale revision cannot discard a newer private draft', (await workspaceAction('status', context)).changes.some(change => change.path === 'tool.txt'))
|
||||
const cli = JSON.parse(execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'workspace-changes', '--workspace', workspace, '--session', context.sessionId, '--state-root', stateRoot],
|
||||
{ windowsHide: true, encoding: 'utf8', timeout: 30_000 }))
|
||||
check('fresh maintenance process reloads the exact saved owner and revision at Unicode caret paths', cli.sessionId === context.sessionId && cli.revision === (await revision()).id && cli.changes.some((change: { path: string }) => change.path === 'tool.txt'))
|
||||
await run('git status --short', { runtimes: ['git'] })
|
||||
result = await run('echo git-draft>git.txt && git add git.txt && git commit -q -m persistent-private-commit', { runtimes: ['git'] })
|
||||
check('draft Git commits persist without a host write approval', result.ok && result.data.git?.scope === 'draft' && result.data.git.persistence === 'saved' && !await fs.stat(join(workspace, 'git.txt')).catch(() => null))
|
||||
result = await run('git log -1 --oneline && type git.txt', { runtimes: ['git'] })
|
||||
check('next command sees both private Git history and private worktree bytes', result.ok && result.data.stdout.includes('persistent-private-commit') && result.data.stdout.includes('git-draft'))
|
||||
result = await run('git log --all --oneline', { runtimes: ['git'], sessionId: 'other-session' })
|
||||
check('private Git history is isolated by session as well as project', result.ok && !result.data.stdout.includes('persistent-private-commit'))
|
||||
const beforeCrash = await revision()
|
||||
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let workerError = ''; worker.stderr.on('data', (bytes: Buffer) => { workerError = (workerError + bytes.toString()).slice(-4000) })
|
||||
const workerClosed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
|
||||
let marker: { runId: string } | undefined
|
||||
for (let attempt = 0; attempt < 240; attempt++) {
|
||||
marker = await workspaceRecord<{ runId: string }>(join(root, 'checkout.json')).catch(() => undefined)
|
||||
if (marker && await fs.stat(join(stateRoot, 'runs', marker.runId, 'stage/workspace/crash.txt')).catch(() => null)) break
|
||||
if (worker.exitCode !== null) throw new Error(`Worker exited before mutation: ${workerError}`)
|
||||
await new Promise(done => setTimeout(done, 40))
|
||||
}
|
||||
assert.ok(marker, workerError); worker.kill(); await workerClosed
|
||||
let recovered = false
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { await workspaceAction('recover', context); recovered = true; break }
|
||||
catch (error) { if (!/活跃|活动|未清理/.test((error as Error).message)) throw error; await new Promise(done => setTimeout(done, 60)) }
|
||||
}
|
||||
check('explicit recovery reclaims a dead main-process lock and checks the native lease', recovered && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
result = await run('git branch --list lost-on-crash && if exist crash.txt (exit /b 9) else (type git.txt)', { runtimes: ['git'] })
|
||||
check('actual host-process death restores prior draft and private Git revision', result.ok && result.data.stdout.includes('git-draft') && !result.data.stdout.includes('lost-on-crash') && !await fs.stat(join(workspace, 'crash.txt')).catch(() => null) && beforeCrash.tree.some(entry => entry.path === 'git.txt'))
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('git log --all --oneline && if exist git.txt exit /b 9', { runtimes: ['git'] })
|
||||
check('discard archives private history and removes uncommitted private files', result.ok && !result.data.stdout.includes('persistent-private-commit'))
|
||||
await run('echo committed-despite-metadata-fault>recovery-host.txt')
|
||||
const activePath = join(root, 'active.json'), savedActive = join(root, 'held-active.json')
|
||||
committed = await workspaceAction('commit', { ...context, approve: async () => { await fs.rename(activePath, savedActive); await fs.mkdir(activePath); return true } })
|
||||
check('post-host-commit metadata failure preserves the actual committed outcome', committed.writeback === 'committed' && committed.state === 'interrupted' && (await fs.readFile(join(workspace, 'recovery-host.txt'), 'utf8')).includes('committed-despite-metadata-fault'))
|
||||
await fs.rmdir(activePath); await fs.rename(savedActive, activePath)
|
||||
status = await workspaceAction('recover', context)
|
||||
check('recovery resolves the native committed transaction before advancing the draft baseline', status.state === 'ready' && status.changes.length === 0 && status.lastTransactionId === committed.transactionId)
|
||||
await fs.writeFile(join(workspace, 'private.txt'), 'DUMMY_POLICY_FILTERED_CONTENT')
|
||||
await run('type private.txt', { runtimes: ['git'] })
|
||||
const policy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['private.txt'] } })
|
||||
status = await workspaceAction('status', context)
|
||||
await assert.rejects(run('type private.txt'), /策略.*已变化/)
|
||||
check('policy change makes the old draft stale without returning denied filenames', status.state === 'stale' && status.changes.length === 0)
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('if exist private.txt (exit /b 9) else (git log --all --oneline)', { runtimes: ['git'] })
|
||||
check('discard under new policy rebuilds filtered files and resets old private history', result.ok && !result.data.stdout.includes('DUMMY_POLICY_FILTERED_CONTENT'))
|
||||
const policy2 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy2.revision, rules: { deniedPaths: [], writeback: 'deny' } })
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('echo policy-private>denied-host.txt')
|
||||
await assert.rejects(workspaceAction('commit', context), /禁止写回/)
|
||||
check('writeback-deny permits private drafting while refusing host commit', result.ok && result.data.writeback === 'deferred' && !await fs.stat(join(workspace, 'denied-host.txt')).catch(() => null))
|
||||
const policy3 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy3.revision, rules: { writeback: 'ask' } })
|
||||
await workspaceAction('discard', context)
|
||||
await fs.writeFile(join(workspace, 'package.json'), '{"name":"workspace-test","version":"1.0.0"}')
|
||||
await fs.writeFile(join(workspace, 'package-lock.json'), '{"name":"workspace-test","lockfileVersion":3}')
|
||||
await fs.mkdir(join(workspace, 'node_modules/offline-fixture'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, 'node_modules/offline-fixture/index.js'), 'module.exports="DEPENDENCY_OK"')
|
||||
await importSandboxToolchain('node', process.execPath)
|
||||
await importSandboxDependencies(workspace, 'node')
|
||||
result = await run('node -e "require(\'fs\').writeFileSync(\'node-result.txt\',require(\'offline-fixture\'))"', { runtimes: ['node'], dependencies: ['node'] })
|
||||
check('read-only dependency injection works in the retained physical tree', result.ok && result.data.toolchains?.[0].kind === 'node' && !await fs.stat(join(root, 'worktree/node_modules')).catch(() => null))
|
||||
result = await run('type node-result.txt')
|
||||
check('dependency layers detach before checkpoint without losing project outputs', result.ok && result.data.stdout.includes('DEPENDENCY_OK'))
|
||||
const privateCheckpoint = await revision()
|
||||
await fs.writeFile(join(root, 'worktree/node-result.txt'), 'incomplete-private-sync')
|
||||
await fs.writeFile(join(root, 'mutation.json'), JSON.stringify({ version: 1, revision: privateCheckpoint.id }))
|
||||
await assert.rejects(run('echo unsafe>must-not-run.txt'), /中断|未确定/)
|
||||
await workspaceAction('recover', context)
|
||||
result = await run('type node-result.txt')
|
||||
check('private sync intent blocks reuse until explicit checkpoint recovery', result.ok && result.data.stdout.includes('DEPENDENCY_OK') && !await fs.stat(join(workspace, 'must-not-run.txt')).catch(() => null))
|
||||
const completed = await revision()
|
||||
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: result.data.runId, revision: completed.previousRevision, physicalIdentity: completed.physicalIdentity }))
|
||||
await workspaceAction('recover', context)
|
||||
check('recovery accepts a completed checkpoint whose checkout acknowledgement was interrupted', (await revision()).id === completed.id && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
const failedAgain = await run('echo incomplete>node-result.txt && exit /b 7'), restored = await revision()
|
||||
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: failedAgain.data.runId, revision: restored.previousRevision, physicalIdentity: privateCheckpoint.physicalIdentity }))
|
||||
await workspaceAction('recover', context)
|
||||
check('recovery is repeatable after rollback checkpoint publication before marker removal', (await revision()).id === restored.id)
|
||||
await fs.mkdir(join(workspace, 'source-tree/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'source-tree/sub/data.bin'), Buffer.from([0, 1, 128, 255]))
|
||||
result = await run('move source-tree renamed-tree && mkdir empty\\nested')
|
||||
committed = await workspaceAction('commit', context)
|
||||
check('persistent tree rename and empty directories commit through the native batch', result.ok && committed.writeback === 'committed' && !await fs.stat(join(workspace, 'source-tree')).catch(() => null) && (await fs.readFile(join(workspace, 'renamed-tree/sub/data.bin'))).equals(Buffer.from([0, 1, 128, 255])) && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
|
||||
await restoreTransaction(committed.transactionId!, workspace)
|
||||
result = await run('if exist renamed-tree (exit /b 9) else (dir source-tree\\sub)')
|
||||
check('native host restore is imported as an external change into a clean retained tree', result.ok && result.data.workspace?.changes.length === 0)
|
||||
await assert.rejects(run('rmdir /s /q source-tree && echo changed-type>source-tree'), /文件\/目录互换/)
|
||||
result = await run('dir source-tree\\sub')
|
||||
check('unsupported private file-directory swaps are rejected before publishing a poisoned revision', result.ok && result.data.stdout.includes('data.bin'))
|
||||
const activeCommand = run('echo READY_FOR_STATUS && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 2"')
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(join(root, 'checkout.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
const observedAt = Date.now(), busy = await workspaceAction('status', context)
|
||||
check('status remains responsive while its owner is running a native command', busy.state === 'busy' && Date.now() - observedAt < 1500)
|
||||
await activeCommand
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'owned-runner.exe'))
|
||||
result = await run('owned-runner.exe --seal-workspace "%CD%"')
|
||||
check('sandbox code cannot invoke the host-only ACL sealing entry point', !result.ok && /host-only/.test(result.data.stderr))
|
||||
await fs.unlink(join(workspace, 'owned-runner.exe'))
|
||||
const livePolicy = await readSandboxPolicy()
|
||||
const revoke = setTimeout(() => { void updateSandboxPolicy({ expectedRevision: livePolicy.revision, rules: { deniedPaths: ['file.txt'] } }) }, 1100)
|
||||
try { await assert.rejects(run('echo revoked>node-result.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"'), /策略已变化/) }
|
||||
finally { clearTimeout(revoke) }
|
||||
status = await workspaceAction('status', context)
|
||||
check('policy revocation interrupts a live draft command and leaves old checkpoint stale', status.state === 'stale' && status.changes.length === 0 && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
console.log(`SANDBOX_WORKSPACE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_WORKSPACE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceWriter } from '../src/main/sandbox/filesystem-writer'
|
||||
import { digest } from '../src/main/sandbox/transactions'
|
||||
import { commitChanges, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { execFileSync, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Native write integration requires Windows')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-write-'))
|
||||
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
const writer = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
const before = await writer.inspect('sample.txt', true)
|
||||
check('native transaction reads exact bytes under a pinned root', before.bytes?.toString() === 'before')
|
||||
await writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('after'))
|
||||
check('replacement succeeds while original target remains pinned', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await assert.rejects(writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('wrong')), /changed/)
|
||||
check('stale expected content cannot overwrite the target', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
} finally { await writer.close() }
|
||||
await fs.mkdir(join(workspace, 'nested'))
|
||||
await fs.writeFile(join(workspace, 'nested/data.bin'), Buffer.from([0, 255, 1, 127]))
|
||||
const outside = join(fixture, 'outside'); await fs.mkdir(outside)
|
||||
await fs.writeFile(join(outside, 'data.bin'), 'OUTSIDE_UNCHANGED')
|
||||
const pinned = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
await pinned.inspect('nested/data.bin', true)
|
||||
const moved = await fs.rename(join(workspace, 'nested'), join(workspace, 'moved')).then(() => true, () => false)
|
||||
check('pinned ancestor cannot be renamed into a junction attack', !moved)
|
||||
const rootMoved = await fs.rename(workspace, join(fixture, 'replaced-root')).then(() => true, () => false)
|
||||
check('workspace root remains pinned for the whole transaction session', !rootMoved)
|
||||
await pinned.replace('nested/data.bin', digest(Buffer.from([0, 255, 1, 127])), Buffer.alloc(0))
|
||||
check('nested binary-to-empty replacement preserves byte semantics', (await fs.stat(join(workspace, 'nested/data.bin'))).size === 0)
|
||||
await pinned.replace('created.bin', null, Buffer.from([0, 128, 255]))
|
||||
await assert.rejects(pinned.replace('created.bin', null, Buffer.from('overwrite')), /changed/)
|
||||
check('creation refuses to overwrite a target that already exists', (await fs.readFile(join(workspace, 'created.bin'))).equals(Buffer.from([0, 128, 255])))
|
||||
for (const path of ['../outside/data.bin', '.env', 'stream.txt:ads', '.ycode-sandbox-owned.tmp']) await assert.rejects(pinned.replace(path, null, Buffer.from('blocked')))
|
||||
check('outside, sensitive, ADS and reserved transaction paths are denied', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.symlink(outside, join(workspace, 'junction'), 'junction')
|
||||
await assert.rejects(pinned.replace('junction/data.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')))
|
||||
check('native relative opens reject a junction target', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.unlink(join(workspace, 'junction'))
|
||||
await fs.link(join(outside, 'data.bin'), join(workspace, 'hardlink.bin'))
|
||||
await assert.rejects(pinned.replace('hardlink.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')), /hard link/)
|
||||
check('native replacement rejects hard-linked files', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.unlink(join(workspace, 'hardlink.bin'))
|
||||
const busy = await fs.open(join(workspace, 'sample.txt'), 'r+')
|
||||
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /win32=32/) }
|
||||
finally { await busy.close() }
|
||||
check('existing writable handles fail closed without relaxed sharing', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await fs.chmod(join(workspace, 'sample.txt'), 0o444)
|
||||
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /read-only/) }
|
||||
finally { await fs.chmod(join(workspace, 'sample.txt'), 0o666) }
|
||||
check('read-only targets are rejected before handoff', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await pinned.replace('created.bin', digest(Buffer.from([0, 128, 255])), null)
|
||||
check('native deletion removes only the verified file object', !await fs.stat(join(workspace, 'created.bin')).catch(() => null))
|
||||
} finally { await pinned.close() }
|
||||
await fs.rename(join(workspace, 'nested'), join(workspace, 'released'))
|
||||
check('session close releases ancestor pins', (await fs.stat(join(workspace, 'released'))).isDirectory())
|
||||
const context = { workspaceRoot: workspace, sessionId: 'write-session', runId: 'write-run', toolCallId: 'write-call', approve: async () => true }
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'runner-copy.exe'))
|
||||
const hostOnly = await executeSandboxCommand('runner-copy.exe --workspace-write "%CD%" -', context)
|
||||
check('sandbox descendants cannot invoke the host transaction entry point', !hostOnly.ok && /host-only/.test(hostOnly.data.stderr))
|
||||
await fs.unlink(join(workspace, 'runner-copy.exe'))
|
||||
const tool = await executeTool('write_file', { path: 'implicit/a/b/file.txt', content: 'implicit-content' }, context)
|
||||
const transactionId = (tool.data as { transactionId?: string })?.transactionId
|
||||
check('default file tool commits through the native transaction backend', tool.ok && !!transactionId && await fs.readFile(join(workspace, 'implicit/a/b/file.txt'), 'utf8') === 'implicit-content')
|
||||
const journal = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId!, 'manifest.json'), 'utf8'))
|
||||
check('journal binds root identity and records implicit parents and mutation intent', journal.version === 3 && journal.workspaceIdentity && journal.directories.length === 3 && journal.mutations.length === 1)
|
||||
await restoreTransaction(transactionId!, workspace)
|
||||
check('recovery removes implicitly created parents as well as file bytes', !await fs.stat(join(workspace, 'implicit')).catch(() => null))
|
||||
let attempts = 0
|
||||
const guardedId = await commitChanges(workspace, [{ path: 'released/data.bin', beforeHash: digest(Buffer.alloc(0)), after: Buffer.from('guarded') }], context, undefined, {
|
||||
beforeApply: async () => {
|
||||
for (let i = 0; i < 20; i++) if (!await fs.rename(join(workspace, 'released'), join(workspace, 'attack-moved')).then(() => true, () => false)) attempts++
|
||||
}, check: async () => undefined
|
||||
})
|
||||
check('real commit rejects repeated ancestor replacement attempts after preflight', attempts === 20 && await fs.readFile(join(workspace, 'released/data.bin'), 'utf8') === 'guarded' && await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await restoreTransaction(guardedId, workspace)
|
||||
await fs.mkdir(join(workspace, 'delete-tree')); await fs.writeFile(join(workspace, 'delete-tree/original.txt'), 'original')
|
||||
await assert.rejects(commitChanges(workspace, [{ path: 'delete-tree/original.txt', beforeHash: digest(Buffer.from('original')), after: null }], context, undefined,
|
||||
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'delete-tree/external.txt'), 'external') }, check: async () => undefined }, [{ path: 'delete-tree', before: true, after: false }]))
|
||||
check('late directory additions stop deletion and compensate earlier file removals', await fs.readFile(join(workspace, 'delete-tree/original.txt'), 'utf8') === 'original' && await fs.readFile(join(workspace, 'delete-tree/external.txt'), 'utf8') === 'external')
|
||||
await assert.rejects(commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('after')), after: Buffer.from('approved') }], context, undefined,
|
||||
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-edit') }, check: async () => undefined }))
|
||||
check('post-preflight external file edits survive a rejected commit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-edit')
|
||||
for (const route of ['command', 'file-tool']) {
|
||||
const targetRoot = join(fixture, `approval-${route}`), movedRoot = `${targetRoot}-old`
|
||||
await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval')
|
||||
const approve = async (): Promise<boolean> => { await fs.rename(targetRoot, movedRoot); await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval'); return true }
|
||||
if (route === 'command') await assert.rejects(executeSandboxCommand('echo approved>sample.txt', { ...context, workspaceRoot: targetRoot, approve }), /identity changed|被替换/)
|
||||
else assert.equal((await executeTool('write_file', { path: 'sample.txt', content: 'approved' }, { ...context, workspaceRoot: targetRoot, approve })).ok, false)
|
||||
check(`${route} approval cannot authorize a replacement root with identical contents`, await fs.readFile(join(targetRoot, 'sample.txt'), 'utf8') === 'before-approval' && await fs.readFile(join(movedRoot, 'sample.txt'), 'utf8') === 'before-approval')
|
||||
}
|
||||
const rootRecovery = join(fixture, 'root-recovery'); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'before')
|
||||
const rootTransaction = await commitChanges(rootRecovery, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }])
|
||||
await fs.rename(rootRecovery, `${rootRecovery}-old`); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'after')
|
||||
await assert.rejects(restoreTransaction(rootTransaction, rootRecovery), /identity changed/)
|
||||
check('recovery refuses a same-path replacement root with matching file hashes', await fs.readFile(join(rootRecovery, 'sample.txt'), 'utf8') === 'after')
|
||||
await fs.writeFile(join(workspace, 'first.txt'), 'first-before'); await fs.writeFile(join(workspace, 'second.txt'), 'second-before')
|
||||
let phase = 0
|
||||
const killed = await commitChanges(workspace, [
|
||||
{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('first-after') },
|
||||
{ path: 'second.txt', beforeHash: digest(Buffer.from('second-before')), after: Buffer.from('second-after') }
|
||||
], context, undefined, { beforeApply: async () => undefined, check: async () => {
|
||||
if (++phase !== 2) return
|
||||
const query = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${process.pid}' | Where-Object { $_.Name -eq 'ycode-sandbox.exe' } | Select-Object -ExpandProperty ProcessId`
|
||||
const pids = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { encoding: 'utf8', windowsHide: true }).trim().split(/\s+/).map(Number).filter(Number.isSafeInteger)
|
||||
assert.equal(pids.length, 1); process.kill(pids[0]); await new Promise(done => setTimeout(done, 40))
|
||||
} }).then(() => null, error => error as Error & { transactionId?: string; recoveryRequired?: boolean })
|
||||
check('native backend death reports recovery-required after a partial batch', killed?.recoveryRequired && !!killed.transactionId && await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-after')
|
||||
await restoreTransaction(killed!.transactionId!, workspace)
|
||||
check('a new native session recovers a batch after actual backend death', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && await fs.readFile(join(workspace, 'second.txt'), 'utf8') === 'second-before')
|
||||
const handoff = await commitChanges(workspace, [{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('handoff-after') }])
|
||||
const manifestPath = join(stateRoot, 'transactions', handoff, 'manifest.json'), manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
|
||||
const intent = manifest.mutations[0], oldScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-old.tmp`), newScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-new.tmp`)
|
||||
await fs.unlink(join(workspace, 'first.txt')); await fs.writeFile(oldScratch, 'first-before'); await fs.writeFile(newScratch, 'handoff-after')
|
||||
manifest.status = 'applying'; manifest.mutations = []; await fs.writeFile(manifestPath, JSON.stringify(manifest))
|
||||
await restoreTransaction(handoff, workspace)
|
||||
check('interrupted-handoff disk fixture restores absent basename and cleans verified scratch', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && !await fs.stat(oldScratch).catch(() => null) && !await fs.stat(newScratch).catch(() => null))
|
||||
const blocked = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
const nonce = randomUUID().replaceAll('-', ''), scratch = join(workspace, `.ycode-sandbox-${nonce}-old.tmp`)
|
||||
await fs.writeFile(scratch, 'foreign-content')
|
||||
await assert.rejects(blocked.cleanup('first.txt', nonce, digest(Buffer.from('first-before')), null), /changed externally/)
|
||||
check('scratch cleanup refuses unrelated or externally changed contents', await fs.readFile(scratch, 'utf8') === 'foreign-content')
|
||||
} finally { await blocked.close() }
|
||||
const dying = await NativeWorkspaceWriter.create(workspace)
|
||||
const child = Reflect.get(dying, 'child') as ChildProcess
|
||||
child.kill(); await new Promise(done => setTimeout(done, 30))
|
||||
await assert.rejects(dying.replace('first.txt', digest(Buffer.from('first-before')), Buffer.from('never')))
|
||||
await dying.close()
|
||||
check('dead native channel cannot fall back to an ordinary host write', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before')
|
||||
const cliRoot = join(fixture, 'CLI work \u4e2d\u6587 ^'); await fs.mkdir(cliRoot); await fs.writeFile(join(cliRoot, 'sample.txt'), 'cli-before')
|
||||
const cliId = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('cli-after') }])
|
||||
execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'restore', cliId, '--workspace', cliRoot, '--state-root', stateRoot], { windowsHide: true, timeout: 30_000, stdio: 'pipe' })
|
||||
check('actual maintenance CLI restores v3 at a space Unicode caret path', await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before')
|
||||
for (const version of [1, 2]) {
|
||||
const id = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('legacy-after') }])
|
||||
const path = join(stateRoot, 'transactions', id, 'manifest.json'), legacy = JSON.parse(await fs.readFile(path, 'utf8'))
|
||||
legacy.version = version; delete legacy.workspaceIdentity; delete legacy.mutationJournal; delete legacy.mutations
|
||||
if (version === 1) delete legacy.directories
|
||||
await fs.writeFile(path, JSON.stringify(legacy))
|
||||
await restoreTransaction(id, cliRoot)
|
||||
const upgraded = JSON.parse(await fs.readFile(path, 'utf8'))
|
||||
check(`legacy v${version} recovery uses native backend and upgrades root identity`, await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before' && upgraded.version === 3 && !!upgraded.workspaceIdentity)
|
||||
}
|
||||
console.log(`SANDBOX_WRITE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_WRITE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -128,20 +128,6 @@ async function main(): Promise<void> {
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^(?:\.\/|\.\.\/)omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
export class OmpKernel {
|
||||
constructor() {}
|
||||
prompt() { return Promise.resolve({ type: 'response', success: true }); }
|
||||
abort() { return Promise.resolve({ type: 'response', success: true }); }
|
||||
dispose() {}
|
||||
}
|
||||
export const detectOmpPath = () => null;
|
||||
export const getOmpInfo = () => ({ available: false, exePath: null, profile: 'ycode', model: null });
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* 最小闭环冒烟测试(不依赖 Electron、不依赖真实 API Key)。
|
||||
*
|
||||
* 用一个本地假上游(OpenAI 兼容 SSE)驱动真实的 gateway + agent loop,
|
||||
* 验证:SSE 流式解析 → 流式工具调用组装 → 工具执行 → 上下文回填 → 终态。
|
||||
*
|
||||
* 运行:npm run smoke
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import type { ChatEvent, ChatMessage } from '../src/shared/types'
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) {
|
||||
console.log(` ✓ ${label}`)
|
||||
} else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sseChunk(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`
|
||||
}
|
||||
|
||||
/** 假上游:第 1 次请求返回工具调用,第 2 次返回最终文本 */
|
||||
function startFakeUpstream(): Promise<{ server: Server; port: number; requests: unknown[] }> {
|
||||
const requests: unknown[] = []
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
let raw = ''
|
||||
req.on('data', (c) => (raw += c))
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(raw || '{}')
|
||||
requests.push(body)
|
||||
const round = requests.length
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
|
||||
if (round === 1) {
|
||||
// 分片下发工具调用,模拟真实上游的增量行为
|
||||
res.write(
|
||||
sseChunk({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_abc',
|
||||
type: 'function',
|
||||
function: { name: 'list_dir', arguments: '{"pa' }
|
||||
}
|
||||
]
|
||||
},
|
||||
finish_reason: null
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
res.write(
|
||||
sseChunk({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, function: { arguments: 'th":"."}' } }]
|
||||
},
|
||||
finish_reason: null
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }))
|
||||
} else {
|
||||
for (const piece of ['工作目录里有 ', 'a.txt', ' 和 ', 'b.txt', '。']) {
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: { content: piece } }] }))
|
||||
}
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }))
|
||||
}
|
||||
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
resolve({ server, port, requests })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ---------- 准备临时工作目录 ----------
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), 'ycode-smoke-'))
|
||||
await writeFile(join(workspaceRoot, 'a.txt'), 'hello a', 'utf8')
|
||||
await writeFile(join(workspaceRoot, 'b.txt'), 'hello b', 'utf8')
|
||||
|
||||
const { server, port, requests } = await startFakeUpstream()
|
||||
|
||||
try {
|
||||
const gateway = new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
model: 'fake-model',
|
||||
apiKey: 'test-key',
|
||||
temperature: 0.7,
|
||||
maxToolRounds: 4
|
||||
})
|
||||
|
||||
const events: ChatEvent[] = []
|
||||
const history: ChatMessage[] = []
|
||||
|
||||
console.log('\n[1] Agent 闭环:工具调用 → 执行 → 回填 → 终态')
|
||||
const result = await runTurn({
|
||||
runId: 'run-smoke',
|
||||
history,
|
||||
userText: '列出工作目录里有哪些文件',
|
||||
signal: new AbortController().signal,
|
||||
emit: (event) => events.push(event),
|
||||
gateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 4,
|
||||
workspaceRoot
|
||||
})
|
||||
|
||||
check('上游被调用 2 次(工具轮 + 收尾轮)', requests.length === 2, `实际 ${requests.length}`)
|
||||
|
||||
const textDeltas = events.filter((e) => e.type === 'text')
|
||||
check('收到流式文本增量 ≥ 5 段', textDeltas.length >= 5, `实际 ${textDeltas.length}`)
|
||||
|
||||
check(
|
||||
'最终文本拼接正确',
|
||||
result.content === '工作目录里有 a.txt 和 b.txt。',
|
||||
`实际「${result.content}」`
|
||||
)
|
||||
|
||||
check('产生 1 条工具调用记录', result.tools.length === 1, `实际 ${result.tools.length}`)
|
||||
const tool = result.tools[0]
|
||||
check('工具名为 list_dir', tool?.name === 'list_dir', `实际 ${tool?.name}`)
|
||||
check(
|
||||
'流式分片的 arguments 被正确拼成 JSON',
|
||||
JSON.stringify(tool?.args) === JSON.stringify({ path: '.' }),
|
||||
`实际 ${JSON.stringify(tool?.args)}`
|
||||
)
|
||||
check('工具执行成功', tool?.status === 'ok', `实际 ${tool?.status}`)
|
||||
check('工具结果含 a.txt', tool?.summary?.includes('a.txt') === true)
|
||||
|
||||
const doneEvent = events.find((e) => e.type === 'done')
|
||||
check('发出 done 终态事件', doneEvent !== undefined)
|
||||
|
||||
// 第 2 次请求应包含回填的 assistant(tool_calls) 与 tool 结果
|
||||
const second = requests[1] as { messages: Array<Record<string, unknown>> }
|
||||
check(
|
||||
'第 2 次请求回填了 assistant.tool_calls',
|
||||
Array.isArray(second?.messages) &&
|
||||
second.messages.some((m) => m.role === 'assistant' && Array.isArray(m.tool_calls))
|
||||
)
|
||||
check(
|
||||
'第 2 次请求回填了 tool 角色结果',
|
||||
Array.isArray(second?.messages) &&
|
||||
second.messages.some(
|
||||
(m) => m.role === 'tool' && String(m.content ?? '').includes('a.txt')
|
||||
)
|
||||
)
|
||||
|
||||
console.log('\n[2] 工具安全边界:工作区逃逸必须被拒绝')
|
||||
const escape = await executeTool('read_file', { path: '../../../../etc/passwd' }, { workspaceRoot })
|
||||
check('越界读取被拒绝', escape.ok === false, `实际 ok=${escape.ok}`)
|
||||
check('拒绝理由可读', escape.content.includes('工作区内'))
|
||||
|
||||
const unknown = await executeTool('not_a_tool', {}, { workspaceRoot })
|
||||
check('未知工具被拒绝', unknown.ok === false)
|
||||
|
||||
const normal = await executeTool('read_file', { path: 'a.txt' }, { workspaceRoot })
|
||||
check('工作区内读取正常', normal.ok === true && normal.content.includes('hello a'))
|
||||
|
||||
console.log('\n[3] 取消与错误传播')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
const cancelled = await runTurn({
|
||||
runId: 'run-cancel',
|
||||
history: [],
|
||||
userText: 'x',
|
||||
signal: abortController.signal,
|
||||
emit: () => {},
|
||||
gateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 4,
|
||||
workspaceRoot
|
||||
}).then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.name
|
||||
)
|
||||
check('已取消的信号会中断回合', cancelled === 'AbortError', `实际 ${cancelled}`)
|
||||
|
||||
const badGateway = new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
model: 'fake-model',
|
||||
apiKey: '',
|
||||
temperature: 0.7,
|
||||
maxToolRounds: 2
|
||||
})
|
||||
const missingKey = await runTurn({
|
||||
runId: 'run-nokey',
|
||||
history: [],
|
||||
userText: 'x',
|
||||
signal: new AbortController().signal,
|
||||
emit: () => {},
|
||||
gateway: badGateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 2,
|
||||
workspaceRoot
|
||||
}).then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
)
|
||||
check('缺少 Key 时给出明确错误', String(missingKey).includes('API Key'), `实际 ${missingKey}`)
|
||||
} finally {
|
||||
server.close()
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\n全部通过 ✓\n' : `\n${failures} 项失败 ✗\n`)
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('冒烟测试异常终止:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,314 +0,0 @@
|
||||
// Actual Electron preload/IPC + installed OMP and HTTP workers, using loopback model responses only.
|
||||
const fs = require('node:fs/promises'), path = require('node:path')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-app-'))
|
||||
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
await fs.writeFile(path.join(directory, 'workspace', 'fixture.txt'), 'PROJECT_FIXTURE_CONTENT')
|
||||
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
|
||||
"export * from './src/main/subagents/scheduler';", "export * from './src/main/subagents/worker';",
|
||||
"export * from './src/main/agent/loop';", "export * from './src/main/gateway';",
|
||||
"export * from './src/shared/message-parts';"
|
||||
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: path.join(directory, 'host.cjs'), logLevel: 'warning' })
|
||||
const env = { ...process.env, YCODE_AGENTS_TEST_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
|
||||
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data; process.stdout.write(data) })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
const report = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8').catch(() => '{"result":"MISSING"}'))
|
||||
console.log(JSON.stringify({ directory, result: report.result, exitCode: code }))
|
||||
if (code || report.result !== 'PASS') { console.error(output.slice(-8000)); process.exitCode = 1 }
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron'), assert = require('node:assert/strict'), http = require('node:http')
|
||||
const directory = process.env.YCODE_AGENTS_TEST_DIRECTORY, workspaceRoot = path.join(directory, 'workspace')
|
||||
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
|
||||
app.on('window-all-closed', () => {})
|
||||
BrowserWindow.prototype.show = function () {}
|
||||
const checks = [], requests = [], models = new Map(), rendererErrors = []
|
||||
let server, main, phase = 'boot', apiBase, host, direct
|
||||
const pause = ms => new Promise(done => setTimeout(done, ms))
|
||||
function check(label, condition = true) { assert.ok(condition, label); checks.push(label); console.log('AGENT_CHECK ' + label) }
|
||||
function sse(res, model, call, final) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: model + '-call-' + Math.random(), type: 'function', function: { name: call.name, arguments: JSON.stringify(call.args) } }] }
|
||||
: { role: 'assistant', content: final }
|
||||
res.end([{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta, finish_reason: null }] },
|
||||
{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 } }]
|
||||
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
|
||||
}
|
||||
const read = (source, window = main) => window.webContents.executeJavaScript(source)
|
||||
const invoke = (namespace, method, ...args) => read('window.ycode[' + JSON.stringify(namespace) + '][' + JSON.stringify(method) + '](...' + JSON.stringify(args) + ')')
|
||||
async function until(operation, label, attempts = 600) {
|
||||
for (let index = 0; index < attempts; index++) { const value = await operation(); if (value) return value; await pause(30) }
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
const profile = model => ({ id: model, name: model, model, providerId: 'custom', protocol: 'openai', baseUrl: apiBase + '/v1', apiKey: 'loopback-only',
|
||||
endpoint: { api: 'openai-completions', baseUrl: apiBase + '/v1', checkedAt: Date.now() }, supportsToolCall: true,
|
||||
supportsImages: false, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 })
|
||||
async function start(model, permissionMode = 'full-access') {
|
||||
models.set(model, { stage: 0, childActive: 0, peak: 0, held: false, rootAfterOff: false })
|
||||
await invoke('models', 'save', profile(model))
|
||||
const workspace = await invoke('workspace', 'attach', workspaceRoot)
|
||||
const session = await invoke('session', 'create', model, { modelId: model, workspaceId: workspace.attachedWorkspaceId })
|
||||
const run = await invoke('chat', 'send', { sessionId: session.id, text: 'ROOT_ONLY_SECRET_MARKER ' + model, permissionMode })
|
||||
return { session, run }
|
||||
}
|
||||
async function finish(session) {
|
||||
return until(async () => {
|
||||
const assistant = (await invoke('session', 'messages', session.id)).filter(message => message.role === 'assistant').at(-1)
|
||||
return assistant && assistant.status !== 'streaming' && !await invoke('chat', 'activeRun', session.id) ? assistant : null
|
||||
}, 'terminal assistant ' + session.id, 1600)
|
||||
}
|
||||
async function taskWindow(session) {
|
||||
await invoke('session', 'openWindow', session.id)
|
||||
const window = await until(() => BrowserWindow.getAllWindows().find(item => item !== main && item.getTitle() === 'YCode 任务'), 'task window')
|
||||
window.setContentSize(1440, 900)
|
||||
await until(() => read('!!document.querySelector(".composer__area .conversation-team-collaboration__trigger")', window), 'input-area members')
|
||||
window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
|
||||
return window
|
||||
}
|
||||
async function handler(req, res) {
|
||||
try {
|
||||
let raw = ''; for await (const chunk of req) raw += chunk
|
||||
const body = JSON.parse(raw || '{}'), model = body.model, state = models.get(model)
|
||||
assert.ok(state, 'known controlled model')
|
||||
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
|
||||
const users = body.messages.filter(message => message.role === 'user')
|
||||
const worker = users.some(message => JSON.stringify(message.content).includes('你是 YCode 主智能体委派的执行成员'))
|
||||
const serialized = JSON.stringify(body.messages), toolResults = body.messages.filter(message => message.role === 'tool')
|
||||
requests.push({ model, worker, body })
|
||||
if (worker) {
|
||||
assert.ok(!serialized.includes('ROOT_ONLY_SECRET_MARKER'), 'child does not inherit private root history')
|
||||
const names = body.tools.map(tool => tool.function.name)
|
||||
assert.ok(!names.includes('spawn_agent') && !names.includes('execute_command') && !names.includes('browser'), 'worker schema is bounded')
|
||||
const isA = serialized.includes('CHILD_A'), write = isA || model.includes('approval')
|
||||
const marker = isA ? 'CHILD_A_RESULT' : 'CHILD_B_RESULT'
|
||||
if (model.includes('held')) {
|
||||
state.held = true
|
||||
return // Held response is aborted by the application's child cancellation.
|
||||
}
|
||||
if (toolResults.length === 0) {
|
||||
state.childActive++; state.peak = Math.max(state.peak, state.childActive)
|
||||
await pause(250)
|
||||
state.childActive--
|
||||
if (write) {
|
||||
assert.ok(names.includes('write_file'), 'declared writer receives write tools')
|
||||
return sse(res, model, { name: 'write_file', args: { path: model.includes('approval') ? 'approval.txt' : 'agent-a.txt', content: 'WRITTEN_BY_CHILD_A' } })
|
||||
}
|
||||
assert.ok(!names.includes('write_file'), 'read-only child never receives write tools')
|
||||
return sse(res, model, { name: 'read_file', args: { path: 'fixture.txt' } })
|
||||
}
|
||||
assert.ok(write ? serialized.includes('已写入')
|
||||
: serialized.includes('PROJECT_FIXTURE_CONTENT'), 'actual child tool result returns to model')
|
||||
return sse(res, model, undefined, marker)
|
||||
}
|
||||
if (model.includes('disabled')) {
|
||||
assert.ok(!body.tools.some(tool => tool.function.name === 'spawn_agent'), 'disabled switch hides scheduling')
|
||||
if (state.stage++ === 0) return sse(res, model, { name: 'spawn_agent', args: { name: 'forbidden', task: 'forbidden' } })
|
||||
assert.ok(serialized.includes('禁用') || serialized.includes('Tool spawn_agent not found'), 'stale spawn name is rejected by host or native tool registry')
|
||||
return sse(res, model, undefined, 'DISABLED_PARENT_COMPLETE')
|
||||
}
|
||||
const held = model.includes('held'), approval = model.includes('approval')
|
||||
if (model.includes('followup')) {
|
||||
const next = state.stage++
|
||||
if (next === 0) return sse(res, model, { name: 'spawn_agent', args: { name: '持续核对', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
|
||||
if (next === 1 || next === 3) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
|
||||
if (next === 2) {
|
||||
const content = toolResults[0].content
|
||||
const result = JSON.parse(typeof content === 'string' ? content : content.map(part => part.text || '').join(''))
|
||||
return sse(res, model, { name: 'send_agent_message', args: { id: result.id, message: 'FOLLOWUP_CONTEXT 再核对一次,沿用先前结果' } })
|
||||
}
|
||||
assert.ok(serialized.includes('CHILD_B_RESULT'), 'follow-up result reaches parent')
|
||||
return sse(res, model, undefined, 'FOLLOWUP_PARENT_COMPLETE')
|
||||
}
|
||||
if (state.stage++ === 0) {
|
||||
assert.ok(body.tools.some(tool => tool.function.name === 'spawn_agent'), 'enabled root sees scheduling')
|
||||
return sse(res, model, { name: 'spawn_agent', args: { name: '文件分析 A', task: 'CHILD_A 独立完成任务并报告验收结果',
|
||||
access: held ? 'read-only' : 'write', ...(held ? {} : { files: [approval ? 'approval.txt' : 'agent-a.txt'] }) } })
|
||||
}
|
||||
if (held || approval) {
|
||||
if (state.stage === 2) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
|
||||
if (model.includes('toggle-held')) {
|
||||
state.rootAfterOff = !body.tools.some(tool => tool.function.name === 'spawn_agent')
|
||||
assert.ok(state.rootAfterOff, 'next native model request loses spawn after disabling')
|
||||
}
|
||||
return sse(res, model, undefined, 'CONTROL_PARENT_COMPLETE')
|
||||
}
|
||||
if (state.stage === 2) return sse(res, model, { name: 'spawn_agent', args: { name: '资料核对 B', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
|
||||
if (state.stage === 3) return sse(res, model, undefined, 'EARLY_CANDIDATE')
|
||||
assert.ok(serialized.includes('本地调度器已等待子任务') && serialized.includes('CHILD_A_RESULT') && serialized.includes('CHILD_B_RESULT'), 'host requires synthesis with actual child results')
|
||||
return sse(res, model, undefined, 'MAIN_INTEGRATED_RESULT')
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } }))
|
||||
}
|
||||
}
|
||||
app.whenReady().then(async () => {
|
||||
host = require(path.join(directory, 'host.cjs'))
|
||||
server = http.createServer(handler)
|
||||
await new Promise(done => server.listen(0, '127.0.0.1', done))
|
||||
apiBase = 'http://127.0.0.1:' + server.address().port
|
||||
phase = 'http-root-and-workers'
|
||||
const model = 'agent-http', config = { api: 'openai-completions', baseUrl: apiBase + '/v1', model, apiKey: 'loopback-only', temperature: 0, maxToolRounds: 10 }
|
||||
models.set(model, { stage: 0, childActive: 0, peak: 0 })
|
||||
const controller = new AbortController(), events = []
|
||||
direct = new host.SubagentScheduler({ runId: 'http-parent', workspaceRoot, modelName: model, enabled: true, signal: controller.signal,
|
||||
emit: event => events.push(event), capabilities: { specs: [], execute: async () => undefined },
|
||||
worker: (task, tools) => host.createSubagentWorker({ task, tools, model: { profile: profile(model), config }, workspaceRoot,
|
||||
parentRunId: 'http-parent', permissionMode: 'full-access', native: false, emitParent: event => events.push(event) })
|
||||
})
|
||||
const result = await host.runTurn({ runId: 'http-parent', sessionId: 'http-session', workspaceRoot, history: [], userText: 'ROOT_ONLY_SECRET_MARKER',
|
||||
gateway: new host.Gateway(config), model, maxToolRounds: 10, signal: controller.signal, permissionMode: 'full-access',
|
||||
capabilityTools: direct.tools, beforeFinish: () => direct.beforeFinish(), emit: event => events.push(event) })
|
||||
check('HTTP: separate model requests execute and integrate both child results', result.content.includes('MAIN_INTEGRATED_RESULT'))
|
||||
check('HTTP: actual child provider requests overlap', models.get(model).peak >= 2)
|
||||
check('HTTP: real delegated write reaches the declared file', await fs.readFile(path.join(workspaceRoot, 'agent-a.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
|
||||
await direct.close(); direct = undefined
|
||||
phase = 'native-cancelled-write-drain'
|
||||
const drainModel = 'agent-drain', drainEvents = [], drainController = new AbortController()
|
||||
models.set(drainModel, { stage: 0, childActive: 0, peak: 0 })
|
||||
let finishWrite, writeStarted = false, workerSettled = false
|
||||
const drainWorker = await host.createSubagentWorker({
|
||||
task: { id: 'drain-child', name: 'write drain', task: 'CHILD_A', access: 'write', files: ['agent-a.txt'], modelName: drainModel, status: 'running', createdAt: Date.now(), events: [], tools: [] },
|
||||
tools: { specs: [], filterSpecs: specs => specs.filter(spec => ['read_file', 'list_dir', 'write_file', 'edit_file'].includes(spec.function.name)),
|
||||
async execute(name, args) {
|
||||
if (name !== 'write_file') return undefined
|
||||
writeStarted = true
|
||||
return new Promise(resolve => { finishWrite = async () => {
|
||||
const target = path.join(workspaceRoot, args.path), oldContent = await fs.readFile(target, 'utf8')
|
||||
await fs.writeFile(target, 'DRAINED_WRITE')
|
||||
resolve({ ok: true, content: '已写入', data: { path: target, oldContent, newContent: 'DRAINED_WRITE' } })
|
||||
} })
|
||||
}
|
||||
},
|
||||
model: { profile: profile(drainModel), config: { ...config, model: drainModel } }, workspaceRoot,
|
||||
parentRunId: 'drain-parent', native: true, permissionMode: 'full-access', emitParent() {}
|
||||
})
|
||||
const draining = drainWorker.run('CHILD_A 写入后报告', drainController.signal, event => drainEvents.push(event))
|
||||
draining.then(() => { workerSettled = true }, () => { workerSettled = true })
|
||||
await until(() => writeStarted, 'write already in progress')
|
||||
drainController.abort(); await pause(80)
|
||||
check('native child cancellation does not release an unfinished local write', !workerSettled)
|
||||
await finishWrite(); await draining
|
||||
check('cancelled native child retains actual file before/after data for rewind', drainEvents.some(event => event.type === 'tool_result' && event.call.result?.newContent === 'DRAINED_WRITE'))
|
||||
await drainWorker.dispose()
|
||||
phase = 'production-boot'
|
||||
delete process.env.YCODE_DISABLE_GPU; delete process.env.YCODE_DISABLE_GPU_SANDBOX
|
||||
await import(require('node:url').pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main app')
|
||||
await until(() => read('!!window.ycode'), 'preload')
|
||||
await read('window.__agentEvents=[];window.ycode.chat.onEvent(event=>window.__agentEvents.push({runId:event.runId,type:event.type,sequence:event.sequence}));true')
|
||||
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
|
||||
phase = 'production-native'
|
||||
await invoke('agents', 'update', { teamManagement: true })
|
||||
const normal = await start('agent-native')
|
||||
const assistant = await finish(normal.session)
|
||||
check('APP: installed OMP completes the root and both child model conversations', assistant.status === 'done' && assistant.content.includes('MAIN_INTEGRATED_RESULT'))
|
||||
const emitted = await read('window.__agentEvents.filter(event=>event.runId===' + JSON.stringify(normal.run.runId) + ')')
|
||||
check('APP: terminal event follows every child snapshot with a strictly newer sequence', emitted.at(-1).type === 'done'
|
||||
&& emitted.every((event, index) => index === 0 || event.sequence > emitted[index - 1].sequence))
|
||||
const agents = assistant.parts.filter(part => part.type === 'subagent').map(part => part.agent)
|
||||
check('APP: two completed children retain separate transcripts and tool results', agents.length === 2 && agents.every(agent => agent.status === 'completed' && agent.tools.some(tool => tool.status === 'ok')))
|
||||
check('APP: root and children keep the same selected custom model', agents.every(agent => agent.modelName === 'agent-native'))
|
||||
const nativeCount = requests.filter(request => request.model === 'agent-native').length
|
||||
check('APP: reported usage sums all root and child requests without replacing context usage', assistant.runUsage?.total.totalTokens === nativeCount * 110
|
||||
&& assistant.runUsage.main.totalTokens > 0 && assistant.runUsage.subagents.totalTokens > 0 && assistant.contextUsage?.usage.totalTokens === 110)
|
||||
check('APP: isolated observer transport still reports prepared request categories', assistant.contextBreakdown?.counts.system > 0 && assistant.contextBreakdown?.counts.tools > 0)
|
||||
const disk = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
|
||||
check('APP: agent details and aggregated usage survive disk persistence', disk.sessions.find(session => session.id === normal.session.id).messages.some(message => message.id === assistant.id && message.runUsage?.total.totalTokens === nativeCount * 110 && message.parts.filter(part => part.type === 'subagent').length === 2))
|
||||
const window = await taskWindow(normal.session)
|
||||
check('UI: completed team has one input-area entry and no reply duplicates', await read('document.querySelectorAll(".composer__area .conversation-team-collaboration__trigger").length===1&&!document.querySelector(".chat-scroll .conversation-team-collaboration")&&document.querySelector(".conversation-team-collaboration__trigger").textContent.includes("2 位团队成员")&&!document.querySelector(".yc-subagent")&&!!document.querySelector(".yc-run-usage")', window))
|
||||
await read('document.querySelector(".conversation-team-collaboration__trigger").click();document.querySelector(".yc-run-usage>summary").click();true', window)
|
||||
await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===2', window), 'two members')
|
||||
await read('document.querySelector(".conversation-team-collaboration__member").click();true', window)
|
||||
await until(() => read('!!document.querySelector(".conversation-member-detail .msg--assistant")', window), 'member conversation detail')
|
||||
check('UI: selected member opens its child conversation in the existing right detail surface', await read('!!document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId===document.querySelector(".conversation-member-detail")?.dataset.subagentId', window))
|
||||
await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true', window)
|
||||
check('UI: task assignment is separately expandable and child tools use the normal renderer', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("CHILD_A")&&!!document.querySelector(".conversation-member-detail .cr-captured-tool")&&!document.querySelector(".yc-subagent__event")', window))
|
||||
await pause(350)
|
||||
await fs.writeFile(path.join(directory, 'agents-light.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
|
||||
await read('localStorage.setItem("ycode:theme","dark");window.dispatchEvent(new StorageEvent("storage",{key:"ycode:theme",newValue:"dark"}));true', window)
|
||||
await until(() => read('document.documentElement.dataset.theme==="dark"&&document.querySelector(".chat-pane")?.dataset.crTheme==="dark"', window), 'dark conversation theme')
|
||||
await pause(400)
|
||||
await fs.writeFile(path.join(directory, 'agents-dark.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
|
||||
window.destroy()
|
||||
phase = 'disabled-host-gate'
|
||||
await invoke('agents', 'update', { teamManagement: false })
|
||||
const disabled = await start('agent-disabled')
|
||||
const disabledMessage = await finish(disabled.session)
|
||||
check('APP: disabled switch hides tools and rejects an explicit stale tool name', disabledMessage.status === 'done'
|
||||
&& disabledMessage.content.includes('DISABLED_PARENT_COMPLETE') && !disabledMessage.parts.some(part => part.type === 'subagent'))
|
||||
phase = 'toggle-during-run'
|
||||
await invoke('agents', 'update', { teamManagement: true })
|
||||
const toggled = await start('agent-toggle-held')
|
||||
await until(() => models.get('agent-toggle-held').held, 'held child request')
|
||||
await invoke('agents', 'update', { teamManagement: false })
|
||||
const toggledMessage = await finish(toggled.session)
|
||||
check('APP: switch-off cancels the running native child while the parent continues', toggledMessage.status === 'done'
|
||||
&& toggledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled') && models.get('agent-toggle-held').rootAfterOff)
|
||||
phase = 'parent-cancel'
|
||||
await invoke('agents', 'update', { teamManagement: true })
|
||||
const cancelled = await start('agent-cancel-held')
|
||||
await until(() => models.get('agent-cancel-held').held, 'cancellable child')
|
||||
await invoke('chat', 'cancel', cancelled.run.runId)
|
||||
const cancelledMessage = await finish(cancelled.session)
|
||||
check('APP: cancelling the parent also cancels its native children', cancelledMessage.status === 'cancelled'
|
||||
&& cancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
|
||||
phase = 'individual-cancel'
|
||||
const single = await start('agent-single-held')
|
||||
await until(() => models.get('agent-single-held').held, 'individual child')
|
||||
const snapshot = await invoke('chat', 'activeRun', single.session.id)
|
||||
const childId = snapshot.messages.at(-1).parts.find(part => part.type === 'subagent').agent.id
|
||||
check('APP: child cancel IPC rejects an unknown identifier', !(await invoke('chat', 'cancelAgent', single.run.runId, 'unknown')).cancelled)
|
||||
const liveWindow = await taskWindow(single.session)
|
||||
await read('document.querySelector(".conversation-team-collaboration__trigger").click();true', liveWindow)
|
||||
await until(() => read('!!document.querySelector(".conversation-team-collaboration__member[data-status=running]")', liveWindow), 'running member')
|
||||
await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true', liveWindow)
|
||||
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=running] .yc-member-stop")', liveWindow), 'live member detail')
|
||||
check('UI: running child is reachable above the input before the parent finishes', await read('!!document.querySelector(".composer__area .conversation-team-collaboration__member[data-status=running]")', liveWindow))
|
||||
await read('document.querySelector(".yc-member-stop").click();true', liveWindow)
|
||||
const singleMessage = await finish(single.session)
|
||||
check('APP: individual cancellation leaves the parent able to finish', singleMessage.status === 'done' && singleMessage.content.includes('CONTROL_PARENT_COMPLETE'))
|
||||
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=cancelled]")&&!document.querySelector(".yc-member-stop")&&document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow), 'live terminal UI')
|
||||
check('UI: stopping a child updates the open member detail and completes the main reply')
|
||||
await read('document.querySelector(".conversation-member-detail__close").click();true', liveWindow)
|
||||
await until(() => read('!document.querySelector(".conversation-member-detail")', liveWindow), 'close member detail')
|
||||
check('UI: closing member detail leaves the completed parent conversation intact', await read('document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow))
|
||||
liveWindow.destroy()
|
||||
phase = 'legacy-sandbox-mode-inert'
|
||||
const approval = await start('agent-approval', 'default-sandbox')
|
||||
const approvedMessage = await finish(approval.session)
|
||||
check('APP: legacy default-sandbox value is inert; child write lands on the host without an approval prompt',
|
||||
approvedMessage.status === 'done' && (approvedMessage.interactions ?? []).every(item => item.status !== 'pending')
|
||||
&& await fs.readFile(path.join(workspaceRoot, 'approval.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
|
||||
phase = 'native-followup'
|
||||
await invoke('agents', 'update', { teamManagement: true })
|
||||
const followup = await start('agent-followup')
|
||||
const followupMessage = await finish(followup.session)
|
||||
check('APP: a completed native child accepts follow-up instructions in its own context', followupMessage.status === 'done'
|
||||
&& followupMessage.content.includes('FOLLOWUP_PARENT_COMPLETE')
|
||||
&& requests.some(request => request.model === 'agent-followup' && request.worker && JSON.stringify(request.body.messages).includes('FOLLOWUP_CONTEXT')
|
||||
&& JSON.stringify(request.body.messages).includes('CHILD_B_RESULT')))
|
||||
phase = 'delete-native-history'
|
||||
const transcriptDirectory = path.join(directory, 'data', 'subagent-sessions', 'session-' + Buffer.from(normal.session.id).toString('base64url'))
|
||||
check('APP: native child transcripts belong to the parent session directory', await fs.stat(transcriptDirectory).then(info => info.isDirectory(), () => false))
|
||||
await invoke('session', 'remove', normal.session.id)
|
||||
check('APP: deleting the parent removes its native child transcripts', !await fs.stat(transcriptDirectory).then(() => true, () => false))
|
||||
check('UI: no renderer errors', rendererErrors.length === 0)
|
||||
phase = 'done'
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requestCounts: [...models.keys()].map(model => ({ model, requests: requests.filter(row => row.model === model).length })), rendererErrors }, null, 2))
|
||||
console.log('SUBAGENT_APP_PASS ' + directory)
|
||||
server.closeAllConnections(); server.close(); app.quit()
|
||||
}).catch(async error => {
|
||||
console.error(error)
|
||||
await direct?.close().catch(() => undefined)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, error: String(error.stack || error), checks, requests }, null, 2))
|
||||
server?.closeAllConnections(); server?.close(); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ChatMessage } from '../src/shared/types'
|
||||
import type { SubagentSnapshot } from '../src/shared/subagents'
|
||||
import { collectTurnMembers, projectMemberMessage, selectComposerTeam, sortMembers } from '../src/renderer/src/workbuddy/subagents/member-view'
|
||||
import { useWorkbenchStore } from '../src/renderer/src/stores/workbench'
|
||||
|
||||
let checks = 0
|
||||
const check = (name: string, condition: unknown): void => { assert.ok(condition, name); checks++; console.log('PASS ' + name) }
|
||||
const base: SubagentSnapshot = { id: 'a', name: '分析', task: '检查消息', status: 'running', access: 'read-only', files: [], modelName: 'local', createdAt: 1, events: [], tools: [] }
|
||||
const parent: ChatMessage = { id: 'parent-a', role: 'assistant', content: 'parent', createdAt: 1, parts: [
|
||||
{ type: 'subagent', agent: { ...base, status: 'completed' } },
|
||||
{ type: 'subagent', agent: { ...base, id: 'b', name: '验收', status: 'queued' } },
|
||||
{ type: 'subagent', agent: base }
|
||||
] }
|
||||
const members = collectTurnMembers(parent)
|
||||
check('same child snapshot upserts without duplicating membership', members.length === 2 && members[0].agent === base)
|
||||
check('membership remains scoped to its own assistant turn', collectTurnMembers({ ...parent, id: 'next-turn', parts: [] }).length === 0)
|
||||
const next: ChatMessage = { ...parent, id: 'next-turn', parts: [], status: 'streaming' }
|
||||
check('input activity belongs to the active assistant rather than an older team', selectComposerTeam([parent, next], next.id) === undefined)
|
||||
check('an idle latest reply without children does not resurrect an earlier team', selectComposerTeam([parent, { ...next, status: 'done' }]) === undefined)
|
||||
check('hydration with a missing active assistant never falls back to stale members', selectComposerTeam([parent], 'not-loaded') === undefined)
|
||||
check('latest completed team remains reachable from the input area', selectComposerTeam([{ ...parent, status: 'done' }])?.assistantId === parent.id && selectComposerTeam([parent])?.members.length === 2)
|
||||
const completed = [{ ...members[0], agent: { ...base, status: 'completed' as const } }, members[1]]
|
||||
const sorted = sortMembers(completed)
|
||||
check('running or queued members sort first without changing task ordinals', sorted[0].agent.id === 'b' && sorted[0].ordinal === 2 && completed[0].agent.id === 'a')
|
||||
const tool = { id: '1:tool', name: 'read_file', args: { path: 'src/a.ts' }, argsRaw: '{"path":"src/a.ts"}', status: 'ok' as const, output: 'REAL_FILE_OUTPUT' }
|
||||
const child = { ...base, events: [
|
||||
{ id: 'thought', kind: 'reasoning' as const, text: '先核对入口' },
|
||||
{ id: tool.id, kind: 'tool' as const, name: tool.name, status: 'ok' as const, text: 'DISPLAY_SUMMARY' },
|
||||
{ id: 'reply', kind: 'text' as const, text: 'partial reply' }
|
||||
], tools: [tool], result: 'complete reply', status: 'completed' as const, finishedAt: 2000 }
|
||||
const before = JSON.stringify(child)
|
||||
const projected = projectMemberMessage(child)
|
||||
check('detail preserves thinking, tool and reply order', projected.parts?.map(part => part.type).join(',') === 'reasoning,tool,text')
|
||||
check('detail uses actual child tool arguments and output', projected.tools?.[0] === tool && projected.tools[0].output === 'REAL_FILE_OUTPUT')
|
||||
check('complete child result replaces its truncated streaming tail exactly once', projected.content === 'complete reply' && projected.parts?.filter(part => part.type === 'text').length === 1)
|
||||
check('projection never mutates the persisted child snapshot', JSON.stringify(child) === before)
|
||||
check('member reasoning is finished only for a terminal child', projected.parts?.[0].type === 'reasoning' && projected.parts[0].finished === true && projectMemberMessage({ ...child, status: 'running' }).status === 'streaming')
|
||||
check('legacy snapshots retain real tool records when events are absent', projectMemberMessage({ ...base, tools: [tool] }).tools?.[0] === tool)
|
||||
check('cancelled children remain cancelled in the detail view', projectMemberMessage({ ...base, status: 'cancelled' }).status === 'cancelled')
|
||||
|
||||
const api = useWorkbenchStore.getState()
|
||||
api.openView('session-a', 'fileTree')
|
||||
const tab = useWorkbenchStore.getState().sessions['session-a'].activeTabId!
|
||||
api.openMember('session-a', 'parent-a', 'a')
|
||||
check('opening a member preserves existing workbench tabs and opens its surface', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-a'].open && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 1)
|
||||
api.openMember('session-b', 'parent-b', 'b')
|
||||
check('member selection is isolated between conversations', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
|
||||
api.select('session-a', tab)
|
||||
check('selecting a workbench tab exits the member surface without removing child history', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].activeTabId === tab && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
|
||||
api.openMember('session-a', 'parent-a', 'a')
|
||||
await api.openResource('session-a', 'session-a', 'artifact:demo', undefined, { kind: 'artifact', path: 'artifact:demo', name: '产物', content: 'content' })
|
||||
check('opening an artifact from child tools replaces the member surface', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 2)
|
||||
api.closeMember('session-b')
|
||||
check('closing a member preserves its conversation workbench', !useWorkbenchStore.getState().sessions['session-b'].member)
|
||||
console.log('SUBAGENT_DISPLAY_PASS ' + JSON.stringify({ checks }))
|
||||
@@ -1,257 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SubagentScheduler, type SubagentWorker } from '../src/main/subagents/scheduler'
|
||||
import { executeTurnTool, type ConversationToolContext } from '../src/main/agent/conversation-tools'
|
||||
import { toolSpecs } from '../src/main/agent/tools'
|
||||
import type { CapabilityToolSnapshot } from '../src/main/capabilities/runtime'
|
||||
import type { ChatEvent, ChatMessage, ToolCallRecord } from '../src/shared/types'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import { getRevertChanges } from '../src/shared/revert'
|
||||
import { assistantWireHistory } from '../src/main/agent/history'
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-agents-'))
|
||||
const checks: string[] = []
|
||||
function check(label: string, condition: unknown): void { assert.ok(condition, label); checks.push(label); console.log('PASS ' + label) }
|
||||
const pause = (ms = 10): Promise<void> => new Promise(done => setTimeout(done, ms))
|
||||
const base: CapabilityToolSnapshot = { specs: [], execute: async () => undefined }
|
||||
function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: string) {
|
||||
const id = Math.random().toString(36), controller = new AbortController(), events: ChatEvent[] = []
|
||||
const policies = new Map<string, CapabilityToolSnapshot>(), prompts: string[] = [], release = new Map<string, () => void>()
|
||||
let current = 0, peak = 0, disposed = 0
|
||||
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'selected-model', enabled,
|
||||
signal: controller.signal, emit: event => events.push(event), capabilities: base, timeoutMs,
|
||||
worker: async (task, policy): Promise<SubagentWorker> => {
|
||||
policies.set(task.id, policy)
|
||||
return { async run(prompt, signal, emit) {
|
||||
current++; peak = Math.max(peak, current); prompts.push(prompt)
|
||||
emit({ runId: 'child', type: 'usage', usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } })
|
||||
try {
|
||||
if (hold) await new Promise<void>((done, reject) => {
|
||||
const onAbort = (): void => { signal.removeEventListener('abort', onAbort); reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
release.set(task.id, () => { signal.removeEventListener('abort', onAbort); done() })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
emit({ runId: 'child', type: 'text', delta: 'RESULT:' + task.name })
|
||||
return { content: resultText ?? 'RESULT:' + task.name, tools: [] }
|
||||
} finally { current-- }
|
||||
}, async dispose() { disposed++ } }
|
||||
}
|
||||
})
|
||||
const ctx: ConversationToolContext = { runId: id, toolCallId: 'test-call', sessionId: 'parent-' + id, workspaceRoot: root,
|
||||
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: event => events.push(event) }
|
||||
const call = (name: string, args: Record<string, unknown> = {}) => executeTurnTool(name, args, ctx, scheduler.tools)
|
||||
const spawn = async (name: string, extra = {}) => {
|
||||
const result = await call('spawn_agent', { name, task: '独立调查并给出验收结果', ...extra })
|
||||
assert.equal(result.ok, true, result.content); return JSON.parse(result.content) as { id: string; status: string }
|
||||
}
|
||||
return { scheduler, controller, ctx, call, spawn, events, policies, prompts, release,
|
||||
get peak() { return peak }, get current() { return current }, get disposed() { return disposed } }
|
||||
}
|
||||
|
||||
{
|
||||
const state = setup(false)
|
||||
check('disabled switch hides spawn and rejects a direct stale-name invocation', !state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent') && !(await state.call('spawn_agent', { name: 'x', task: 'x' })).ok)
|
||||
state.scheduler.setEnabled(true)
|
||||
check('enabling immediately exposes real scheduling tools', state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent'))
|
||||
check('plan mode cannot bypass the scheduling mode gate', !(await executeTurnTool('spawn_agent', { name: 'x', task: 'x' }, { ...state.ctx, agentMode: 'plan' }, state.scheduler.tools)).ok)
|
||||
const spawned = await Promise.all([1, 2, 3, 4, 5, 6].map(i => state.spawn('任务' + i)))
|
||||
await pause()
|
||||
check('six tasks use five workers with the sixth queued', state.peak === 5 && JSON.parse((await state.call('list_agents')).content).filter((view: { status: string }) => view.status === 'queued').length === 1)
|
||||
const stranger = setup()
|
||||
check('cross-turn agent identifiers cannot be read or cancelled', !(await stranger.call('cancel_agent', { id: spawned[0].id })).ok)
|
||||
await stranger.spawn('跨会话排队')
|
||||
check('the concurrency budget also applies across root conversations', stranger.current === 0)
|
||||
await stranger.scheduler.close()
|
||||
state.scheduler.setEnabled(false)
|
||||
await state.scheduler.beforeFinish()
|
||||
check('disabling cancels running and queued children without aborting the parent', !state.controller.signal.aborted && JSON.parse((await state.call('list_agents')).content).every((view: { status: string }) => view.status === 'cancelled'))
|
||||
check('disabled switch also denies follow-up messages', !(await state.call('send_agent_message', { id: spawned[0].id, message: '继续' })).ok)
|
||||
await state.scheduler.close()
|
||||
check('cleanup disposes the five created child runtimes', state.disposed === 5)
|
||||
}
|
||||
{
|
||||
const state = setup(true, false, 2000, '\u0000'.repeat(60000))
|
||||
await state.spawn('长结果')
|
||||
await pause()
|
||||
const result = await state.call('wait_agent', { timeoutMs: 1000 })
|
||||
check('large escaped results remain bounded complete JSON', result.content.length <= 30000 && JSON.parse(result.content)[0].truncated)
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
const writer = await state.spawn('写文件', { access: 'write', files: ['owned.txt'] })
|
||||
check('write declarations reject directories, escapes and glob patterns', !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['.'] })).ok
|
||||
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['../escape.txt'] })).ok
|
||||
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['*.ts'] })).ok)
|
||||
await pause()
|
||||
const policy = state.policies.get(writer.id)!
|
||||
const workerCtx = { ...state.ctx, runId: 'worker', sessionId: 'child', toolCallId: 'child-write' }
|
||||
check('child schemas hide commands, browsers and nested scheduling', policy.filterSpecs!(toolSpecs()).every(tool => ['list_dir', 'read_file', 'write_file', 'edit_file'].includes(tool.function.name)))
|
||||
check('unlisted shell and nested spawn calls are rejected by the host', !(await executeTurnTool('execute_command', { command: 'echo forbidden' }, workerCtx, policy)).ok
|
||||
&& !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok)
|
||||
check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok)
|
||||
check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok)
|
||||
const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' })
|
||||
check('parent write to a child-leased file is still denied', !blockedWrite.ok)
|
||||
check('the denial names the file and the next step instead of blaming another task', blockedWrite.content.includes('子智能体') && blockedWrite.content.includes('owned.txt'))
|
||||
// 同一回合里并发发出的命令必须都能跑完:以前后到的一条会被判成"文件正由其他任务修改"。
|
||||
const [slow, sibling] = await Promise.all([
|
||||
state.call('execute_command', { command: 'ping -n 2 127.0.0.1 >nul && echo first' }),
|
||||
state.call('execute_command', { command: 'echo second' })
|
||||
])
|
||||
check('same-round shell calls both succeed while a child holds a file lease', slow.ok && sibling.ok
|
||||
&& slow.content.includes('first') && sibling.content.includes('second'))
|
||||
const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy)
|
||||
check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child')
|
||||
state.release.get(writer.id)!()
|
||||
await state.call('wait_agent', { ids: [writer.id], timeoutMs: 1000 })
|
||||
const parentWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent-after' })
|
||||
check('file lease is released after child completion', parentWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'parent-after')
|
||||
const childTool: ToolCallRecord = { id: 'child-write', name: 'write_file', status: 'ok', args: {}, argsRaw: '{}', result: childWrite.data, metadata: childWrite.metadata }
|
||||
const parentTool: ToolCallRecord = { ...childTool, id: 'parent-write', result: parentWrite.data, metadata: parentWrite.metadata }
|
||||
const view = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(view?.type === 'part' && view.part.type === 'subagent')
|
||||
const messages: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 },
|
||||
{ id: 'assistant', role: 'assistant', content: '', createdAt: 1, tools: [parentTool],
|
||||
parts: [{ type: 'subagent', agent: { ...view.part.agent, tools: [childTool] } }], status: 'done' }]
|
||||
const changes = getRevertChanges(messages, 'user')
|
||||
check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
// 同一回合里并发发出的未知足迹工具(命令、连接器等)必须排队串行,而不是互相判成文件冲突。
|
||||
const id = 'root-queue-' + Math.random().toString(36).slice(2)
|
||||
const controller = new AbortController()
|
||||
const started: string[] = [], finished: string[] = []
|
||||
const gates = new Map<string, () => void>()
|
||||
let active = 0, peak = 0
|
||||
const tool = (name: string) => ({ type: 'function', function: { name, description: '排队用例的假工具', parameters: { type: 'object', properties: {} } } })
|
||||
const capabilities: CapabilityToolSnapshot = {
|
||||
specs: [tool('custom_command'), tool('write_file')],
|
||||
async execute(_name, args) {
|
||||
const key = String((args as { key?: unknown; path?: unknown }).key ?? (args as { path?: unknown }).path ?? '')
|
||||
started.push(key); active++; peak = Math.max(peak, active)
|
||||
await new Promise<void>(done => gates.set(key, done))
|
||||
active--; finished.push(key)
|
||||
return { ok: true, content: 'done:' + key }
|
||||
}
|
||||
}
|
||||
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'queue-model', enabled: true,
|
||||
signal: controller.signal, emit: () => {}, capabilities,
|
||||
worker: async () => { throw new Error('排队用例不创建子智能体运行时') } })
|
||||
const ctx: ConversationToolContext = { runId: id, toolCallId: 'queue', sessionId: 'parent-' + id, workspaceRoot: root,
|
||||
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: () => {} }
|
||||
const run = (key: string) => executeTurnTool('custom_command', { key }, ctx, scheduler.tools)
|
||||
const write = (path: string, context = ctx) => executeTurnTool('write_file', { path }, context, scheduler.tools)
|
||||
const startedKey = async (count: number): Promise<string> => {
|
||||
for (let i = 0; i < 200 && started.length < count; i++) await pause(5)
|
||||
return started[count - 1]
|
||||
}
|
||||
const waitForKey = async (key: string): Promise<void> => {
|
||||
for (let i = 0; i < 200 && !started.includes(key); i++) await pause(5)
|
||||
}
|
||||
const calls = [run('a'), run('b'), run('c')]
|
||||
const first = await startedKey(1)
|
||||
check('sibling root tools with unknown footprints are serialized, not rejected', started.length === 1 && peak === 1)
|
||||
gates.get(first)!()
|
||||
const second = await startedKey(2)
|
||||
check('the next sibling starts by itself once the holder finishes', started.length === 2 && peak === 1)
|
||||
gates.get(second)!()
|
||||
gates.get(await startedKey(3))!()
|
||||
const results = await Promise.all(calls)
|
||||
check('every concurrently issued root call succeeds exactly once in first-come order', results.every(result => result.ok)
|
||||
&& new Set(results.map(result => result.content)).size === 3 && finished.length === 3 && peak === 1)
|
||||
// 父写文件期间到达的 spawn_agent 不能声明同一文件:父→子方向也必须互斥。
|
||||
const pendingWrite = write('parent.txt')
|
||||
await waitForKey('parent.txt')
|
||||
const late = await executeTurnTool('spawn_agent', { name: 'late', task: 'same file', access: 'write', files: ['parent.txt'] }, ctx, scheduler.tools)
|
||||
check('a child cannot declare the file the parent is writing right now', !late.ok && late.content.includes('主智能体'))
|
||||
gates.get('parent.txt')!()
|
||||
check('the pending parent write completes normally', (await pendingWrite).ok)
|
||||
// 队首被取消后,后面与持有者不冲突的等待者必须立刻接续。
|
||||
const blocker = write('block.txt')
|
||||
await waitForKey('block.txt')
|
||||
const headController = new AbortController()
|
||||
const head = write('block.txt', { ...ctx, signal: headController.signal })
|
||||
await pause(50)
|
||||
const tail = write('queue-only.txt')
|
||||
await pause(30)
|
||||
check('a conflicting waiter holds the queue and an unrelated one waits behind it', !started.includes('queue-only.txt'))
|
||||
headController.abort()
|
||||
const headRejection = await head.then(() => undefined, (error: Error) => error)
|
||||
await waitForKey('queue-only.txt')
|
||||
check('cancelling the head lets the next non-conflicting waiter start at once',
|
||||
headRejection?.name === 'AbortError' && started.includes('queue-only.txt'))
|
||||
gates.get('queue-only.txt')!()
|
||||
await tail
|
||||
gates.get('block.txt')!()
|
||||
await blocker
|
||||
const holder = run('hold')
|
||||
await waitForKey('hold')
|
||||
const queued = run('waiting')
|
||||
await pause(30)
|
||||
check('a sibling arriving while the footprint is held waits instead of failing', !started.includes('waiting'))
|
||||
controller.abort()
|
||||
const rejection = await queued.then(() => undefined, (error: Error) => error)
|
||||
check('a queued root call is cancelled instead of hanging when the turn aborts', rejection?.name === 'AbortError')
|
||||
gates.get('hold')!()
|
||||
await holder
|
||||
await scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
const reader = await state.spawn('只读')
|
||||
await pause()
|
||||
const policy = state.policies.get(reader.id)!
|
||||
check('read-only child cannot write even with inherited full-access permission', !(await executeTurnTool('write_file', { path: 'read-only.txt', content: 'no' }, { ...state.ctx, runId: 'reader' }, policy)).ok)
|
||||
const after = state.scheduler.beforeFinish()
|
||||
state.release.get(reader.id)!()
|
||||
check('parent finalization waits and supplies unread child results for synthesis', (await after)?.includes('RESULT:只读'))
|
||||
check('collected results do not create an infinite finalization loop', await state.scheduler.beforeFinish() === undefined)
|
||||
const part = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(part?.type === 'part')
|
||||
check('HTTP cold history preserves child results as explicitly labeled task data', JSON.stringify(assistantWireHistory({
|
||||
id: 'history', role: 'assistant', content: '', createdAt: 0, parts: [part.part]
|
||||
})).includes('RESULT:只读'))
|
||||
const follow = await state.call('send_agent_message', { id: reader.id, message: '补充核对' })
|
||||
await pause()
|
||||
state.release.get(reader.id)!()
|
||||
await state.call('wait_agent', { ids: [reader.id], timeoutMs: 1000 })
|
||||
check('completed child keeps its runtime for follow-up instructions', follow.ok && state.prompts.length === 2 && state.prompts[1] === '补充核对')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
await state.spawn('取消测试')
|
||||
await pause()
|
||||
state.controller.abort()
|
||||
await state.scheduler.close()
|
||||
check('parent cancellation ends all children and releases their runtimes', state.current === 0 && state.disposed === 1)
|
||||
}
|
||||
{
|
||||
const state = setup(true, true, 50)
|
||||
await state.spawn('超时')
|
||||
await pause(100)
|
||||
const result = JSON.parse((await state.call('list_agents')).content)
|
||||
check('child execution timeout reaches a terminal state', result[0].status === 'cancelled')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
for (let i = 0; i < 16; i++) await state.spawn('有界任务' + i)
|
||||
check('task creation is bounded per root turn', !(await state.call('spawn_agent', { name: 'overflow', task: 'overflow' })).ok)
|
||||
const first = state.events.find(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(first?.type === 'part' && first.part.type === 'subagent')
|
||||
let message: ChatMessage = { id: 'recover', role: 'assistant', content: '', createdAt: 0 }
|
||||
message = applyAssistantEvent(message, first)
|
||||
message = applyAssistantEvent(message, { ...first, part: { type: 'subagent', agent: { ...first.part.agent, status: 'running' } } })
|
||||
check('streaming agent snapshots upsert in place', message.parts?.length === 1)
|
||||
message = applyAssistantEvent(message, { runId: state.ctx.runId, type: 'cancelled' })
|
||||
check('cold interrupted history never leaves a child shown as running', message.parts?.[0].type === 'subagent' && message.parts[0].agent.status === 'cancelled')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
await writeFile(join(root, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
|
||||
console.log('SUBAGENT_REGRESSION_PASS ' + JSON.stringify({ checks: checks.length, directory: root }))
|
||||
@@ -1,60 +0,0 @@
|
||||
// Real filesystem + SDK MCP + production fallback Gateway/turn loop. All servers are local fixtures.
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'node:http'
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-gateway-'))
|
||||
const userData = path.join(directory, 'data'), cwd = path.join(directory, 'workspace'), source = path.join(directory, 'skill')
|
||||
await Promise.all([userData, cwd, source].map(folder => fs.mkdir(folder)))
|
||||
await fs.writeFile(path.join(source, 'SKILL.md'), '---\nname: gateway-proof\ndescription: Controlled gateway capability\n---\nGATEWAY_SKILL_PROOF\n')
|
||||
await fs.writeFile(path.join(source, 'guide.txt'), 'GATEWAY_RESOURCE_PROOF')
|
||||
await build({ stdin: { contents: `export * as skills from './src/main/capabilities/skills';export * as mcp from './src/main/capabilities/mcp';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {runTurn} from './src/main/agent/loop';export {Gateway} from './src/main/gateway'`, resolveDir: process.cwd() }, outfile: path.join(directory, 'runtime.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning', plugins: [{ name: 'isolated-path', setup(context) { context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'qa' })); context.onLoad({ filter: /.*/, namespace: 'qa' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` })) } }] })
|
||||
const { skills, mcp, createCapabilitySnapshot, runTurn, Gateway } = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href)
|
||||
const checks = [], events = [], requests = []
|
||||
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
|
||||
let server
|
||||
try {
|
||||
const skill = await skills.importSkill(source)
|
||||
const saved = await mcp.saveMcpServer({ name: 'gateway-connector', transport: 'stdio', command: process.execPath, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] })
|
||||
await mcp.trustMcpServer(saved.id, saved.configHash); await mcp.setMcpEnabled(saved.id, true)
|
||||
const snapshot = await createCapabilitySnapshot([skill.id], cwd)
|
||||
check('resource URI uses source skill scheme', snapshot.references[0].uri, 'skill://gateway-proof')
|
||||
check('manual skill body in actual turn context', snapshot.context.includes('GATEWAY_SKILL_PROOF'))
|
||||
const mcpName = snapshot.specs.find(spec => spec.function.name.startsWith('mcp_echo_')).function.name
|
||||
server = createServer(async (request, response) => {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw), results = body.messages.filter(message => message.role === 'tool')
|
||||
requests.push({ toolNames: body.tools.map(tool => tool.function.name), results: results.map(item => item.content), skillContext: JSON.stringify(body.messages).includes('GATEWAY_SKILL_PROOF') })
|
||||
const calls = [{ name: 'ycode_load_skill', args: { id: skill.id } }, { name: 'ycode_read_skill_resource', args: { id: skill.id, path: 'guide.txt' } }, { name: mcpName, args: { text: 'GATEWAY_ECHO_PROOF' } }]
|
||||
const call = calls[results.length], delta = call ? { tool_calls: [{ index: 0, id: 'call-' + results.length, type: 'function', function: { name: call.name, arguments: JSON.stringify(call.args) } }] } : { content: 'GATEWAY_CAPABILITIES_OK' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(`data: ${JSON.stringify({ id: 'qa', choices: [{ index: 0, delta, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ id: 'qa', choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }] })}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const gateway = new Gateway({ baseUrl: `http://127.0.0.1:${server.address().port}/v1`, apiKey: '', authMode: 'none', model: 'qa', temperature: 0, maxToolRounds: 6 })
|
||||
const signal = new AbortController().signal
|
||||
const result = await runTurn({ runId: 'gateway-capabilities', history: [], userText: 'Run capability proof\n' + snapshot.context, signal, emit: event => events.push(event), gateway, model: 'qa', maxToolRounds: 6, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: snapshot })
|
||||
check('production fallback returns final reply', result.content, 'GATEWAY_CAPABILITIES_OK')
|
||||
check('three actual tool rounds succeeded', result.tools.map(tool => tool.status), ['ok', 'ok', 'ok'])
|
||||
check('auto load returned actual installed bytes', result.tools[0].output.includes('GATEWAY_SKILL_PROOF'))
|
||||
check('resource tool returned actual file bytes', result.tools[1].output, 'GATEWAY_RESOURCE_PROOF')
|
||||
check('MCP returned SDK server result', result.tools[2].output, 'stdio:GATEWAY_ECHO_PROOF')
|
||||
check('MCP display identity survives the fallback turn', [result.tools[2].metadata?.serverName, result.tools[2].metadata?.toolName], ['gateway-connector', 'echo'])
|
||||
check('all dynamic tools reach actual HTTP upstream', requests.every(request => ['ycode_load_skill', 'ycode_read_skill_resource', mcpName].every(name => request.toolNames.includes(name))))
|
||||
check('MCP result is fed back to upstream', requests.at(-1).results.includes('stdio:GATEWAY_ECHO_PROOF'))
|
||||
check('tool completion events precede terminal', events.filter(event => ['tool_result', 'done'].includes(event.type)).map(event => event.type), ['tool_result', 'tool_result', 'tool_result', 'done'])
|
||||
const ctx = { runId: 'qa', toolCallId: 'qa-tool', workspaceRoot: cwd, permissionMode: 'full-access', signal, emit() {} }
|
||||
check('unknown skill ID cannot load', (await snapshot.execute('ycode_load_skill', { id: 'other-workspace' }, ctx)).ok, false)
|
||||
await assert.rejects(snapshot.execute('ycode_read_skill_resource', { id: skill.id, path: '../outside' }, ctx)); checks.push('resource traversal rejected through turn dispatcher')
|
||||
await mcp.setMcpToolEnabled(saved.id, 'echo', false)
|
||||
check('disabled MCP tool rejects an older snapshot', (await snapshot.execute(mcpName, { text: 'NO_EXECUTE' }, ctx)).ok, false)
|
||||
check('next turn omits disabled MCP tool', !(await createCapabilitySnapshot([], cwd)).specs.some(tool => tool.function.name === mcpName))
|
||||
await skills.setSkillEnabled(skill.id, false)
|
||||
await assert.rejects(createCapabilitySnapshot([skill.id], cwd)); checks.push('selected disabled skill rejects before a turn')
|
||||
check('built-in expert manager remains available after user skill disabled', (await createCapabilitySnapshot([], cwd)).specs.some(tool => tool.function.name === 'ycode_load_skill'))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory }))
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requests }, null, 2))
|
||||
} catch (error) { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', checks, error: String(error) }, null, 2)); throw error }
|
||||
finally { await mcp.disposeMcpServers(); if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)) } }
|
||||
@@ -1,76 +0,0 @@
|
||||
/** Production experts module + real isolated FS. Controlled Electron path/MCP state and injected I/O failures. */
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { build } from 'esbuild'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
const output = await fs.mkdtemp(path.resolve('.tmp/expert-review-snapshot-'))
|
||||
const relative = path.relative(path.resolve('.tmp'), output)
|
||||
assert.ok(relative.startsWith('expert-review-snapshot-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
const data = path.join(output, 'data'); await fs.mkdir(data)
|
||||
globalThis.expertReviewMcp = { servers: [], hook: null, calls: [], generation: 1 }
|
||||
await build({ stdin: { contents: `export * from ${JSON.stringify(path.resolve('src/main/capabilities/experts.ts'))};export {createCapabilitySnapshot} from ${JSON.stringify(path.resolve('src/main/capabilities/runtime.ts'))};`, resolveDir: process.cwd(), sourcefile: 'expert-snapshot-review-entry.ts', loader: 'ts' }, outfile: path.join(output, 'experts.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', plugins: [{ name: 'isolated-review-host', setup(builder) {
|
||||
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'review' }))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'review' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(data)}}` }))
|
||||
builder.onResolve({ filter: /^\.\/mcp$/ }, () => ({ path: 'mcp', namespace: 'review-mcp' }))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'review-mcp' }, () => ({ contents: `export async function listMcpServers(){const state=globalThis.expertReviewMcp;state.calls.push('gate');if(state.hook)await state.hook();return state.servers}export async function snapshotMcpTools(){const state=globalThis.expertReviewMcp;state.calls.push('snapshot');const generation=state.generation;return {specs:[{type:'function',function:{name:'controlled_mcp',description:'controlled snapshot',parameters:{type:'object'}}}],describe:()=>({serverName:'worker',toolName:'echo'}),execute:async()=>generation===state.generation?{ok:true,content:'captured'}:{ok:false,content:'CONTROLLED_GENERATION_CHANGED'}}}` }))
|
||||
} }], logLevel: 'warning' })
|
||||
const api = await import(pathToFileURL(path.join(output, 'experts.mjs')).href)
|
||||
const checks = [], packages = path.join(data, 'plugins', 'marketplaces', 'my-experts', 'plugins'), market = path.join(data, 'plugins', 'marketplaces', 'my-experts', '.codebuddy-plugin', 'marketplace.json')
|
||||
const check = (name, actual, expected) => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
function fixture(id, label = 'OLD_LABEL', role = 'OLD_ROLE') {
|
||||
const p = { name: id, version: '1.0.0', description: 'controlled fixture', expertType: 'agent', agentName: id, plugin: id, agents: [`./agents/${id}.md`], displayName: { zh: label, en: label }, profession: { zh: label, en: label }, displayDescription: { zh: '测试专家用于明确受控的隔离验证本地角色技能包数据一致性并保持行为诚实不伪造任何外部环境状态', en: 'controlled' }, defaultInitPrompt: { zh: 'hello', en: 'hello' }, quickPrompts: [{ zh: 'hello', en: 'hello' }, { zh: 'two', en: 'two' }, { zh: 'three', en: 'three' }], tags: [{ zh: 'one', en: 'one' }, { zh: 'two', en: 'two' }, { zh: 'three', en: 'three' }] }
|
||||
return { '.codebuddy-plugin/plugin.json': JSON.stringify(p), [`agents/${id}.md`]: `---\nname: ${id}\ndescription: fixture\n---\n${role}\n` }
|
||||
}
|
||||
async function install(id, files, session = 'owner-' + id) { await api.expertPackageOperation(session, { id, action: 'write', files }); return api.expertPackageOperation(session, { id, action: 'register' }) }
|
||||
const catalogHas = async id => JSON.parse(await fs.readFile(market, 'utf8')).plugins.some(item => item.name === id)
|
||||
const exists = file => fs.stat(file).then(() => true, () => false)
|
||||
const originalRm = fs.rm, originalRename = fs.rename
|
||||
try {
|
||||
let files = fixture('snapshot-expert'), metadata = JSON.parse(files['.codebuddy-plugin/plugin.json'])
|
||||
metadata.mcpServers = {}; files['.codebuddy-plugin/plugin.json'] = JSON.stringify(metadata)
|
||||
await install('snapshot-expert', files)
|
||||
globalThis.expertReviewMcp.hook = async () => { globalThis.expertReviewMcp.hook = null; await install('snapshot-expert', fixture('snapshot-expert', 'NEW_LABEL', 'NEW_ROLE'), 'concurrent-editor') }
|
||||
const snapshot = await api.expertTurnContext('snapshot-expert')
|
||||
check('concurrent register does not mix assistant identity with role', [snapshot.display.name, snapshot.context.includes('OLD_ROLE'), snapshot.context.includes('NEW_ROLE')], ['OLD_LABEL', true, false])
|
||||
check('concurrent register actually publishes new package', (await api.readExpert('snapshot-expert')).prompt, 'NEW_ROLE')
|
||||
files = fixture('dependency-expert'); metadata = JSON.parse(files['.codebuddy-plugin/plugin.json']); metadata.mcpServers = { worker: { command: 'node', args: ['expected.js'] } }; files['.codebuddy-plugin/plugin.json'] = JSON.stringify(metadata)
|
||||
await install('dependency-expert', files)
|
||||
globalThis.expertReviewMcp.servers = [{ id: 'worker', name: 'worker', enabled: true, trusted: true, status: 'connected', transport: 'stdio', command: 'node', args: ['other.js'] }]
|
||||
await assert.rejects(api.activateExpert('dependency-expert'), /参数/); checks.push('same-name trusted connector with different args is rejected')
|
||||
globalThis.expertReviewMcp.servers[0].args = ['expected.js']; check('matching actual connector allows activation', (await api.activateExpert('dependency-expert')).id, 'dependency-expert')
|
||||
await install('remove-expert', fixture('remove-expert'))
|
||||
fs.rm = async (target, options) => { if (path.basename(String(target)).startsWith('.remove-')) { await fs.unlink(path.join(String(target), 'agents', 'remove-expert.md')); throw Object.assign(Error('Injected partial tomb cleanup failure'), { code: 'EIO' }) }; return originalRm.call(fs, target, options) }
|
||||
await api.removeExpert('remove-expert'); fs.rm = originalRm
|
||||
check('partial delete cleanup never resurrects catalog registration', await catalogHas('remove-expert'), false)
|
||||
check('partial delete cleanup leaves original managed identity absent', await exists(path.join(packages, 'remove-expert')), false)
|
||||
await install('rollback-expert', fixture('rollback-expert'))
|
||||
fs.rename = async (from, to) => { if (path.resolve(String(to)) === market) throw Object.assign(Error('Injected atomic catalog commit failure'), { code: 'EIO' }); return originalRename.call(fs, from, to) }
|
||||
await assert.rejects(api.removeExpert('rollback-expert'), /Injected/); fs.rename = originalRename
|
||||
check('delete catalog failure restores full original package', [(await api.readExpert('rollback-expert')).prompt, await catalogHas('rollback-expert')], ['OLD_ROLE', true])
|
||||
await api.expertPackageOperation('rollback-editor', { id: 'rollback-expert', action: 'write', files: fixture('rollback-expert', 'EDITED_LABEL', 'EDITED_ROLE') })
|
||||
fs.rename = async (from, to) => { if (path.resolve(String(to)) === market) throw Object.assign(Error('Injected atomic catalog commit failure'), { code: 'EIO' }); return originalRename.call(fs, from, to) }
|
||||
await assert.rejects(api.expertPackageOperation('rollback-editor', { id: 'rollback-expert', action: 'register' }), /Injected/); fs.rename = originalRename
|
||||
check('register catalog failure restores full original package', [(await api.readExpert('rollback-expert')).prompt, await catalogHas('rollback-expert')], ['OLD_ROLE', true])
|
||||
await install('cleanup-expert', fixture('cleanup-expert'))
|
||||
fs.rm = async (target, options) => { if (path.basename(String(target)).startsWith('.backup-') || String(target).includes('expert-drafts') && path.basename(String(target)) === 'cleanup-expert.json') throw Object.assign(Error('Injected postcommit cleanup failure'), { code: 'EIO' }); return originalRm.call(fs, target, options) }
|
||||
const registered = await install('cleanup-expert', fixture('cleanup-expert', 'EDITED_LABEL', 'EDITED_ROLE'), 'cleanup-editor'); fs.rm = originalRm
|
||||
check('postcommit cleanup failure reports genuine committed success', [registered.success, (await api.readExpert('cleanup-expert')).prompt, await catalogHas('cleanup-expert')], [true, 'EDITED_ROLE', true])
|
||||
await api.expertPackageOperation('corrupt-market-editor', { id: 'leak-expert', action: 'write', files: fixture('leak-expert') })
|
||||
await fs.writeFile(market, 'INVALID JSON')
|
||||
const before = (await fs.readdir(packages)).filter(name => name.startsWith('.stage-')).length
|
||||
await assert.rejects(api.expertPackageOperation('corrupt-market-editor', { id: 'leak-expert', action: 'register' }))
|
||||
check('invalid catalog does not leak install stage', (await fs.readdir(packages)).filter(name => name.startsWith('.stage-')).length, before)
|
||||
assert.throws(() => api.expertManagerResource('__proto__'), /不存在/); assert.throws(() => api.expertManagerResource('constructor'), /不存在/); checks.push('builtin manager refuses inherited resource properties')
|
||||
globalThis.expertReviewMcp.calls = []
|
||||
globalThis.expertReviewMcp.hook = async () => { globalThis.expertReviewMcp.hook = null; globalThis.expertReviewMcp.generation++ }
|
||||
const capability = await api.createCapabilitySnapshot([], output, { sessionId: 'controlled-turn', expertId: 'dependency-expert' })
|
||||
check('actual runtime captures MCP before expert dependency validation', globalThis.expertReviewMcp.calls, ['snapshot', 'gate'])
|
||||
const ctx = { runId: 'controlled-turn', toolCallId: 'controlled-call', workspaceRoot: output, permissionMode: 'full-access', signal: new AbortController().signal, emit() {} }
|
||||
check('actual runtime retains captured snapshot when connection generation changes', await capability.execute('controlled_mcp', {}, ctx), { ok: false, content: 'CONTROLLED_GENERATION_CHANGED', metadata: { capability: 'mcp', serverName: 'worker', toolName: 'echo' } })
|
||||
check('active expert refuses inherited resource properties', (await capability.execute('ycode_expert_resource', { path: '__proto__' }, ctx)).ok, false)
|
||||
check('active expert still reads actual package resource', (await capability.execute('ycode_expert_resource', { path: 'agents/dependency-expert.md' }, ctx)).content.includes('OLD_ROLE'), true)
|
||||
await fs.writeFile(path.join(output, 'report.json'), JSON.stringify({ result: 'PASS', checks, sources: await Promise.all(['src/main/capabilities/experts.ts', 'src/main/capabilities/runtime.ts'].map(async file => ({ file, sha256: createHash('sha256').update(await fs.readFile(file)).digest('hex') }))), limits: 'Actual isolated FS + production experts/runtime module; Electron path and MCP state/generation contract are controlled. Actual network/SDK generation guard and WorkBuddy pixels are not asserted.' }, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
|
||||
} finally { fs.rm = originalRm; fs.rename = originalRename }
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Isolated real Electron backend QA; no user registry, OMP or original application. */
|
||||
/** Isolated real Electron backend QA; no user registry or original application. */
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
@@ -14,7 +14,7 @@ if(!process.versions.electron){
|
||||
const sourceSha256=createHash('sha256').update(await fs.readFile('src/main/capabilities/experts.ts')).digest('hex'),moduleSha256=createHash('sha256').update(await fs.readFile(path.join(directory,'experts-under-test.mjs'))).digest('hex')
|
||||
const bootstrap=path.join(directory,'bootstrap.cjs')
|
||||
await fs.writeFile(bootstrap,`const {app}=require('electron');app.setPath('userData',${JSON.stringify(path.join(directory,'data'))});app.setPath('sessionData',${JSON.stringify(path.join(directory,'sessions'))});app.disableHardwareAcceleration();app.whenReady().then(()=>import(${JSON.stringify(import.meta.url)}).catch(e=>{console.error(String(e));app.exit(1)}))`)
|
||||
const env={...process.env,YCODE_EXPERTS_QA_DIRECTORY:directory,YCODE_OMP_HOME:path.join(directory,'omp')};delete env.ELECTRON_RUN_AS_NODE
|
||||
const env={...process.env,YCODE_EXPERTS_QA_DIRECTORY:directory};delete env.ELECTRON_RUN_AS_NODE
|
||||
const reports=[]
|
||||
for(const phase of phases){
|
||||
const code=await new Promise(resolve=>{const child=spawn(createRequire(import.meta.url)('electron'),['--no-sandbox','--disable-gpu-sandbox',bootstrap,phase],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c);const timer=setTimeout(()=>child.kill(),45000);child.on('exit',async code=>{clearTimeout(timer);await fs.writeFile(path.join(directory,phase+'.log'),output);resolve(code??1)})})
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/** Production expert/capability modules + real imported REA packages in an isolated YCode profile.
|
||||
* Electron's getPath is the only host replacement. The actual MCP SDK/registry uses an empty profile.
|
||||
* No original application access, expert script execution, model invocation or network operation.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawnSync} from 'node:child_process'
|
||||
import {pathToFileURL} from 'node:url'
|
||||
import {build} from 'esbuild'
|
||||
import {parseDocument} from 'yaml'
|
||||
|
||||
const output=await fs.mkdtemp(path.resolve('.tmp/imported-expert-qa-'))
|
||||
const userData=path.join(output,'YCode'),workspace=path.join(output,'workspace')
|
||||
await fs.mkdir(userData);await fs.mkdir(workspace)
|
||||
assert.equal(path.basename(userData),'YCode')
|
||||
assert.ok(path.relative(path.resolve('.tmp'),output).startsWith('imported-expert-qa-'))
|
||||
await fs.writeFile(path.join(userData,'mcp-servers.json'),JSON.stringify({version:1,servers:[]}))
|
||||
const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
const checks=[],imports=[],packages=[],resourceChecks=[],sourceShas=[]
|
||||
let networkCalls=0
|
||||
const originalFetch=globalThis.fetch
|
||||
globalThis.fetch=async()=>{networkCalls++;throw Error('QA prohibits network calls')}
|
||||
function check(label,actual,expected=true){
|
||||
const pass=JSON.stringify(actual)===JSON.stringify(expected)
|
||||
checks.push({label,pass,actual,expected});assert.ok(pass,label)
|
||||
}
|
||||
const sources=[
|
||||
{directory:'.tmp/rea-cpp-expert-recovered-20261016',id:'cpp-super-expert',pin:'b90023e9622bd74c55d583051bee3625eb491b552baeea17ae10ac792a352498'},
|
||||
{directory:'.tmp/rea-project-manager-recovered-20261016',id:'senior-project-manager',pin:'304805c85b87e8266294fb3aaf283d45fc71d7977af064fcb1eed1199e9389e6'},
|
||||
{directory:'.tmp/rea-ui-designer-recovered-20261016',id:'ui-designer',pin:'c9c83df190f0f0f871706d52b7e372f20b7ad63fde739f59cf9181ace1d08afa'},
|
||||
{directory:'.tmp/rea-algorithm-recovered-20261016',id:'senior-algorithm-expert',pin:'44ed0738d809fe492a36c697b0edd53b72ed75549f5b773291d05f9cf4a6c66f'}
|
||||
]
|
||||
const sourceFiles=['scripts/import-rea-expert.mjs','src/main/capabilities/experts.ts','src/main/capabilities/runtime.ts','src/main/capabilities/skills.ts','src/main/capabilities/mcp.ts','src/shared/capabilities.ts']
|
||||
const textMember=name=>/\.(?:md|json|txt|yaml|yml|py|js|cjs|mjs|ts|csv|css|html|svg|cs|csproj|slnx|xml|xsd|sh|ps1|LICENSE)$/i.test(name)||['LICENSE','.gitignore'].includes(path.basename(name))
|
||||
function promptBody(raw){const match=/^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(raw);assert.ok(match);return raw.slice(match[0].length).trim()}
|
||||
function metadata(raw){const match=/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);assert.ok(match);const parsed=parseDocument(match[1],{uniqueKeys:true,schema:'core'});assert.equal(parsed.errors.length,0);return parsed.toJS({maxAliasCount:30})}
|
||||
try{
|
||||
for(const file of sourceFiles)sourceShas.push({file,sha256:hash(await fs.readFile(file))})
|
||||
for(const source of sources){
|
||||
const manifest=JSON.parse(await fs.readFile(path.join(source.directory,'evidence-manifest.json'),'utf8'))
|
||||
check(source.id+' fixed REA root identity',manifest.rootSha256,source.pin)
|
||||
const plugin=JSON.parse(await fs.readFile(path.join(source.directory,'.codebuddy-plugin/plugin.json'),'utf8'))
|
||||
check(source.id+' fixed package identity',plugin.name,source.id)
|
||||
const run=spawnSync(process.execPath,['scripts/import-rea-expert.mjs','--source',source.directory,'--user-data',userData],{encoding:'utf8',maxBuffer:4*1024*1024,timeout:60000,windowsHide:true})
|
||||
await fs.writeFile(path.join(output,source.id+'-import.log'),run.stdout+'\n'+run.stderr)
|
||||
check(source.id+' actual import helper exit',run.status,0)
|
||||
const summary=run.stdout.trim().split(/\r?\n/).map(line=>{try{return JSON.parse(line)}catch{return null}}).findLast(item=>item?.result)
|
||||
check(source.id+' actual import helper explicitly PASS',summary?.result,'PASS')
|
||||
const helperReport=JSON.parse(await fs.readFile(path.join(summary.directory,'report.json'),'utf8'))
|
||||
check(source.id+' helper report activated',helperReport.activated,true)
|
||||
imports.push({expertId:source.id,summary,helperReport:path.join(summary.directory,'report.json')})
|
||||
packages.push({...source,manifest,plugin,installedPath:path.join(userData,'plugins','marketplaces','my-experts','plugins',source.id)})
|
||||
}
|
||||
const bundle=path.join(output,'capabilities-under-test.mjs')
|
||||
const built=await build({stdin:{contents:`export * from './src/main/capabilities/experts';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {listMcpServers} from './src/main/capabilities/mcp';`,resolveDir:process.cwd(),loader:'ts'},outfile:bundle,bundle:true,platform:'node',format:'esm',packages:'external',metafile:true,logLevel:'warning',plugins:[{name:'isolated-electron-path-only',setup(builder){
|
||||
builder.onResolve({filter:/^electron$/},()=>({path:'electron',namespace:'imported-expert-qa'}))
|
||||
builder.onLoad({filter:/.*/,namespace:'imported-expert-qa'},()=>({contents:`export const app={getPath(name){if(name!=='userData')throw Error('Unexpected host path '+name);return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false};`}))
|
||||
}}]})
|
||||
await fs.writeFile(path.join(output,'bundle-metafile.json'),JSON.stringify(built.metafile,null,2))
|
||||
const api=await import(pathToFileURL(bundle).href)
|
||||
check('actual MCP SDK uses empty isolated registry',await api.listMcpServers(),[])
|
||||
const listed=await api.listExperts()
|
||||
check('production registry discovers all four genuine imports',listed.map(item=>item.id).sort(),sources.map(item=>item.id).sort())
|
||||
for(const pkg of packages){
|
||||
const {id,plugin,manifest,installedPath}=pkg,document=await api.readExpert(id),binding=await api.activateExpert(id)
|
||||
check(id+' actual expert is valid',document.expert.error??null,null)
|
||||
check(id+' raw localized display name',document.expert.displayName,plugin.displayName)
|
||||
check(id+' raw localized profession',document.expert.profession,plugin.profession)
|
||||
check(id+' activated localized display name',binding.name,plugin.displayName.zh)
|
||||
check(id+' activated localized profession',binding.profession,plugin.profession.zh)
|
||||
check(id+' activation returns same validated default prompt',binding.defaultInitPrompt,plugin.defaultInitPrompt)
|
||||
check(id+' activation identity mapping',JSON.parse(binding.runtimeIdentity.slice('expert-v2:'.length)),{v:2,kind:'expert',expertId:id,pluginRegisteredName:id,agentName:plugin.agentName,source:'custom'})
|
||||
const avatar=manifest.files.find(file=>file.path===plugin.avatar.replace(/^\.\//,''))
|
||||
check(id+' avatar data URL preserves complete PNG SHA',hash(Buffer.from(binding.avatarUrl.split(',')[1],'base64')),avatar.sha256)
|
||||
const agentPath='agents/'+plugin.agentName+'.md',agentRaw=await fs.readFile(path.join(pkg.directory,agentPath),'utf8')
|
||||
check(id+' role body preserved excluding YAML envelope',document.prompt,promptBody(agentRaw))
|
||||
const turn=await api.expertTurnContext(id)
|
||||
check(id+' actual turn includes real role prompt',turn.context.includes(document.prompt))
|
||||
check(id+' actual turn states isolated installed package root',turn.context.includes(JSON.stringify(installedPath)))
|
||||
const agentSkills=metadata(agentRaw).skills??[],expectedPreloads=[]
|
||||
for(const relative of plugin.skills){
|
||||
const key=relative.replace(/^\.\//,'')+'/SKILL.md',rawSkill=await fs.readFile(path.join(pkg.directory,key),'utf8'),skillName=metadata(rawSkill).name,preloaded=agentSkills.includes(skillName)
|
||||
check(id+' YAML-selected skill preload only: '+key,turn.context.includes(rawSkill),preloaded)
|
||||
check(id+' plugin skill remains in demand-load directory: '+key,turn.context.includes(JSON.stringify({name:skillName,path:key,description:metadata(rawSkill).description,preloaded})))
|
||||
if(preloaded)expectedPreloads.push(key)
|
||||
}
|
||||
check(id+' exact startup skill selection',Array.from(turn.context.matchAll(/<expert-skill path="([^"]+)">/g),match=>match[1]),expectedPreloads)
|
||||
const controller=new AbortController(),ctx={runId:'imported-expert-qa',toolCallId:'resource-check',workspaceRoot:workspace,permissionMode:'full-access',signal:controller.signal,emit(){}}
|
||||
const snapshot=await api.createCapabilitySnapshot([],workspace,{sessionId:'qa-'+id,expertId:id})
|
||||
const {defaultInitPrompt:_defaultInitPrompt,...displayBinding}=binding
|
||||
check(id+' runtime snapshot carries true expert identity',snapshot.assistantDisplay,displayBinding)
|
||||
check(id+' runtime exposes actual resource tool',snapshot.specs.some(spec=>spec.function.name==='ycode_expert_resource'))
|
||||
const resourceFiles=manifest.files.filter(file=>textMember(file.path))
|
||||
check(id+' exact complete resource index',Object.keys(document.files).sort(),resourceFiles.map(file=>file.path).sort())
|
||||
let installedBytes=0
|
||||
for(const file of manifest.files){
|
||||
const original=await fs.readFile(path.join(pkg.directory,file.path)),installed=await fs.readFile(path.join(installedPath,file.path))
|
||||
assert.equal(hash(original),file.sha256);assert.equal(hash(installed),file.sha256);assert.equal(installed.length,file.bytes);installedBytes+=installed.length
|
||||
}
|
||||
check(id+' every installed file preserves full source SHA/bytes',installedBytes,manifest.files.reduce((n,file)=>n+file.bytes,0))
|
||||
for(const file of resourceFiles){
|
||||
const result=await snapshot.execute('ycode_expert_resource',{path:file.path},ctx)
|
||||
const actualSha=typeof result?.content==='string'?hash(Buffer.from(result.content,'utf8')):null
|
||||
resourceChecks.push({expertId:id,path:file.path,sourceSha256:file.sha256,returnedSha256:actualSha,ok:result?.ok,pass:result?.ok===true&&actualSha===file.sha256})
|
||||
assert.equal(result?.ok,true,id+' resource success: '+file.path);assert.equal(actualSha,file.sha256,id+' resource full original SHA: '+file.path)
|
||||
check(id+' runtime advertises resource: '+file.path,snapshot.context.includes('- '+file.path))
|
||||
}
|
||||
check(id+' all real resource bytes preserved through production tool',resourceChecks.filter(item=>item.expertId===id).every(item=>item.pass))
|
||||
for(const bad of ['__proto__','constructor','toString','../README.md','..\\README.md','C:/outside-fixture.txt','/outside-fixture.txt','skills/../../README.md','%2e%2e/README.md','']){
|
||||
check(id+' rejects inherited/traversal/absolute resource '+JSON.stringify(bad),(await snapshot.execute('ycode_expert_resource',{path:bad},ctx))?.ok,false)
|
||||
}
|
||||
check(id+' legitimate ./ resource prefix works',hash((await snapshot.execute('ycode_expert_resource',{path:'./'+agentPath},ctx)).content),manifest.files.find(file=>file.path===agentPath).sha256)
|
||||
check(id+' binary avatar is not exposed as UTF8',(await snapshot.execute('ycode_expert_resource',{path:plugin.avatar},ctx))?.ok,false)
|
||||
for(const marker of ['.downloaded_at','.created-by-session'])check(id+' does not forge source host marker '+marker,await fs.stat(path.join(installedPath,marker)).then(()=>true,()=>false),false)
|
||||
}
|
||||
const ui=packages.find(pkg=>pkg.id==='ui-designer'),fonts=ui.manifest.files.filter(file=>file.path.endsWith('.ttf'))
|
||||
check('UI package exact full byte count',ui.manifest.files.reduce((n,file)=>n+file.bytes,0),6249920)
|
||||
check('UI package retains all 54 fonts',fonts.length,54)
|
||||
check('UI font SHA retention independently checked',await Promise.all(fonts.map(async file=>hash(await fs.readFile(path.join(ui.installedPath,file.path)))===file.sha256)),fonts.map(()=>true))
|
||||
check('PM C#/XML/XSD/csproj are genuinely readable',resourceChecks.filter(item=>item.expertId==='senior-project-manager'&&/\.(?:cs|csproj|xml|xsd)$/.test(item.path)).length,48)
|
||||
check('PM BOM csproj raw SHA preserved',resourceChecks.filter(item=>item.expertId==='senior-project-manager'&&item.path.endsWith('.csproj')).map(item=>item.pass),[true,true])
|
||||
check('UI HTML source exposed',resourceChecks.some(item=>item.expertId==='ui-designer'&&item.path.endsWith('/viewer.html')&&item.pass))
|
||||
check('UI licenses exposed',resourceChecks.some(item=>item.expertId==='ui-designer'&&/LICENSE$/i.test(item.path)&&item.pass))
|
||||
check('algorithm verifier Python source exposed',resourceChecks.some(item=>item.expertId==='senior-algorithm-expert'&&item.path.endsWith('/verify_shortest_paths.py')&&item.pass))
|
||||
check('no fetch/API call performed',networkCalls,0)
|
||||
for(const source of sourceShas)check('tested source unchanged '+source.file,hash(await fs.readFile(source.file)),source.sha256)
|
||||
await fs.writeFile(path.join(output,'report.json'),JSON.stringify({result:'PASS',checks,imports,packages:packages.map(({manifest,plugin,...rest})=>({...rest,files:manifest.files.length,bytes:manifest.files.reduce((n,file)=>n+file.bytes,0),sourceRootSha256:manifest.rootSha256,declaredSkills:plugin.skills})),resourceChecks,sourceShas,bundleSha256:hash(await fs.readFile(bundle)),networkCalls,limits:['Real imported files and production expert/capability/actual empty MCP SDK profile; Electron getPath only is a QA adapter.','No model API, original expert script, font/browser rendering, .NET build or business workflow was executed.','Original package evidence is inherited from SHA-verified REA recovered manifests; this test never accesses WorkBuddy.']},null,2))
|
||||
console.log(JSON.stringify({result:'PASS',checks:checks.length,packages:packages.length,textResources:resourceChecks.length,output}))
|
||||
}catch(error){
|
||||
await fs.writeFile(path.join(output,'report.json'),JSON.stringify({result:'FAIL',error:error.stack,checks,imports,resourceChecks,sourceShas,networkCalls},null,2))
|
||||
console.error(JSON.stringify({result:'FAIL',error:error.message,output}));process.exitCode=1
|
||||
}finally{globalThis.fetch=originalFetch}
|
||||
@@ -8,8 +8,7 @@ const directory = await fs.mkdtemp(path.resolve('.tmp/mcp-native-ui-'))
|
||||
await Promise.all(['data', 'sessions', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const bootstrap = path.join(directory, 'bootstrap.cjs')
|
||||
await fs.writeFile(bootstrap, '(' + nativeQa.toString() + ')()')
|
||||
const env = { ...process.env, YCODE_MCP_UI_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
|
||||
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_MCP_UI_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_MODEL_API_KEY
|
||||
const code = await new Promise(resolve => {
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), ['--no-sandbox', '--disable-gpu-sandbox', bootstrap], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Isolated production IPC/persistence, real file tools, Gateway, SDK MCP and actual OMP.
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createServer } from 'node:http'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/mode-policy-'))
|
||||
const userData = path.join(directory, 'data'), cwd = path.join(directory, 'workspace'), ompHome = path.join(directory, 'omp-home')
|
||||
await Promise.all([userData, cwd, ompHome].map(p => fs.mkdir(p)))
|
||||
await fs.writeFile(path.join(cwd, 'seed.txt'), 'MODE_READ_PROOF')
|
||||
await Promise.all(['.git', 'node_modules'].map(p => fs.mkdir(path.join(cwd, p))))
|
||||
const checks = [], requests = [], engineResults = [], handlers = new Map()
|
||||
const state = globalThis.__YCODE_MODE_QA__ = { userData, cwd, handlers, expertWrites: 0, activations: 0 }
|
||||
const previousOmpHome = process.env.YCODE_OMP_HOME
|
||||
process.env.YCODE_OMP_HOME = ompHome
|
||||
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
const electronSource = `const s=globalThis.__YCODE_MODE_QA__;export const app={getPath:n=>n==='home'?s.cwd:s.userData,getName:()=>'YCode',getVersion:()=>'0.1.0'};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:v=>Buffer.from(v),decryptString:v=>v.toString()};export const ipcMain={handle:(n,f)=>s.handlers.set(n,f)};export const dialog={showOpenDialog:async()=>({canceled:true,filePaths:[]})};export const shell={openPath:async()=>''};export class BrowserWindow{};export class WebContentsView{}`
|
||||
const plugins = [{ name: 'isolated-electron', setup(b) {
|
||||
b.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mode-qa' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-qa' }, () => ({ contents: electronSource, loader: 'js' }))
|
||||
b.onResolve({ filter: /^\.\/terminal-host$/ }, () => ({ path: 'terminal', namespace: 'mode-no-terminal' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-no-terminal' }, () => ({ contents: `const no=()=>{throw Error('Unexpected native terminal')};export const createTerminal=no,getTerminal=no,writeTerminal=no,resizeTerminal=no,clearTerminal=no,closeTerminal=no,rebindTerminal=no,disposeTerminalHosts=no`, loader: 'js' }))
|
||||
// Expert activation is controlled here; production mode validation and atomic session writes run unchanged.
|
||||
b.onResolve({ filter: /(?:^\.\/capabilities\/experts$|^\.\/experts$)/ }, () => ({ path: 'experts', namespace: 'mode-experts' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-experts' }, () => ({ contents: `const s=globalThis.__YCODE_MODE_QA__;export async function activateExpert(id){s.activations++;return{id}};export function expertManagerDocument(){return{content:'Controlled manager'}};export const expertManagerAdapter=()=>'',expertManagerResource=()=>'';export async function expertPackageOperation(){s.expertWrites++;return{success:true}};export const expertTurnContext=async()=>undefined;export const listExperts=async()=>[],readExpert=async()=>({}),removeExpert=async()=>{},expertPackageOperationTool=()=>{},disposeExperts=async()=>{},subscribeExperts=()=>()=>{},installExpertFromPath=async()=>{},openExpertPath=async()=>{}`, loader: 'js' }))
|
||||
} }]
|
||||
await build({ stdin: { contents: `export {registerIpc,disposeOmpKernels} from './src/main/ipc';export * as sessions from './src/main/sessions';export * as config from './src/main/config';export * as workspaces from './src/main/workspaces';export * as mcp from './src/main/capabilities/mcp';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {runTurn} from './src/main/agent/loop';export {runOmpTurn} from './src/main/agent/omp-turn';export {executeConversationTool,executeTurnTool} from './src/main/agent/conversation-tools';export * as policy from './src/main/agent/mode-policy';export {Gateway} from './src/main/gateway';export {OmpKernel,detectOmpPath} from './src/main/omp';export {prepareOmpModel} from './src/main/omp-model'`, resolveDir: process.cwd() }, outfile: path.join(directory, 'runtime.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', plugins, logLevel: 'silent' })
|
||||
const api = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href)
|
||||
let upstream, kernel
|
||||
const invoke = (name, ...args) => handlers.get(name)({}, ...args)
|
||||
try {
|
||||
await api.config.setWorkspaceRoot(cwd)
|
||||
api.registerIpc(() => ({ webContents: { send() {} } }))
|
||||
const session = await invoke('session:create', 'mode fixture', { agentMode: 'plan' })
|
||||
check('IPC create persists plan', session.agentMode, 'plan')
|
||||
check('plan creation clears expert', session.expertId, null)
|
||||
await assert.rejects(invoke('session:create', 'bad', { agentMode: 'unknown' }), /无效的对话模式/); checks.push('invalid create mode rejected')
|
||||
await assert.rejects(invoke('session:update-context', session.id, { agentMode: null }), /无效的对话模式/); checks.push('invalid IPC update rejected')
|
||||
check('invalid mode leaves old mode', (await api.sessions.getSessionContext(session.id)).agentMode, 'plan')
|
||||
await invoke('session:update-context', session.id, { expertId: 'controlled-expert' })
|
||||
check('expert selection restores craft', (await api.sessions.getSessionContext(session.id)).agentMode, 'craft')
|
||||
await invoke('session:update-context', session.id, { agentMode: 'ask' })
|
||||
const askContext = await api.sessions.getSessionContext(session.id)
|
||||
check('existing conversation enters ask and clears expert atomically', [askContext.agentMode, askContext.expertId], ['ask', null])
|
||||
await Promise.all([invoke('session:update-context', session.id, { agentMode: 'plan' }), invoke('session:update-context', session.id, { agentMode: 'ask' })])
|
||||
check('serialized mode mutations keep latest request', (await api.sessions.getSessionContext(session.id)).agentMode, 'ask')
|
||||
const disk = JSON.parse(await fs.readFile(path.join(userData, 'sessions.json'), 'utf8'))
|
||||
check('mode and expert clear reach real sessions JSON', [disk.sessions[0].agentMode, disk.sessions[0].expertId], ['ask', null])
|
||||
const refreshed = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href + '?reload=1')
|
||||
check('fresh module hydrates persisted mode', (await refreshed.sessions.getSessionContext(session.id)).agentMode, 'ask')
|
||||
const listing = await invoke('resources:list', session.id, 'intentionally-invalid-workspace')
|
||||
check('resources list prioritizes session root', listing.some(x => x.name === 'seed.txt'))
|
||||
check('resources list excludes hidden dependency roots', !listing.some(x => ['.git', 'node_modules'].includes(x.name)))
|
||||
await assert.rejects(invoke('resources:list', undefined, 'invalid-id'), /空间不存在/); checks.push('resource registry id cannot become arbitrary root')
|
||||
await assert.rejects(invoke('resources:list', 'invalid-session', undefined), /会话不存在/); checks.push('invalid session cannot fall back to a global resource root')
|
||||
const ctx = { runId: 'mode-qa', toolCallId: 'mode-tool', workspaceRoot: cwd, permissionMode: 'full-access', signal: new AbortController().signal, emit() {} }
|
||||
for (const agentMode of ['plan', 'ask']) {
|
||||
check(agentMode + ' reads real file', (await api.executeConversationTool('read_file', { path: 'seed.txt' }, { ...ctx, agentMode })).content.includes('MODE_READ_PROOF'))
|
||||
for (const name of ['write_file', 'edit_file', 'execute_command', 'mcp_unknown', 'ycode_expert_package', 'unknown_tool']) {
|
||||
const result = await api.executeConversationTool(name, { path: 'seed.txt', content: 'BAD', oldText: 'MODE_READ_PROOF', newText: 'BAD', command: 'echo BAD', action: 'write' }, { ...ctx, agentMode })
|
||||
check(agentMode + ' execution denies ' + name, result.ok, false)
|
||||
}
|
||||
check(agentMode + ' todo policy', (await api.executeConversationTool('create_plan', { steps: ['Inspect'] }, { ...ctx, agentMode })).ok, agentMode === 'plan')
|
||||
}
|
||||
check('readonly tool denials leave real source file unchanged', await fs.readFile(path.join(cwd, 'seed.txt'), 'utf8'), 'MODE_READ_PROOF')
|
||||
const saved = await api.mcp.saveMcpServer({ name: 'mode-fixture', transport: 'stdio', command: process.execPath, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] })
|
||||
await api.mcp.trustMcpServer(saved.id, saved.configHash); await api.mcp.setMcpEnabled(saved.id, true)
|
||||
const craftSnapshot = await api.createCapabilitySnapshot(['builtin:expert-manager'], cwd, { sessionId: session.id, agentMode: 'craft' })
|
||||
const mcpName = craftSnapshot.specs.find(x => x.function.name.startsWith('mcp_echo_')).function.name
|
||||
for (const agentMode of ['plan', 'ask']) {
|
||||
const snap = await api.createCapabilitySnapshot([], cwd, { sessionId: session.id, agentMode })
|
||||
check(agentMode + ' hides SDK MCP and expert package tool', !snap.specs.some(x => x.function.name === mcpName || x.function.name === 'ycode_expert_package'))
|
||||
check(agentMode + ' direct capability invocation blocked despite forged craft ctx', (await snap.execute(mcpName, { text: 'BAD' }, { ...ctx, agentMode: 'craft' })).ok, false)
|
||||
check(agentMode + ' blocks craft snapshot before dynamic dispatch', (await api.executeTurnTool(mcpName, { text: 'BAD' }, { ...ctx, agentMode }, craftSnapshot)).ok, false)
|
||||
check(agentMode + ' blocks expert package register before dispatch', (await api.executeTurnTool('ycode_expert_package', { id: 'bad', action: 'register' }, { ...ctx, agentMode }, craftSnapshot)).ok, false)
|
||||
}
|
||||
check('expert package mutation never reached service', state.expertWrites, 0)
|
||||
const noCalls = (await fs.readFile(path.join(directory, 'mcp-events.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse)
|
||||
check('restricted requests never reached actual SDK callTool', noCalls.filter(x => x.type === 'call').length, 0)
|
||||
let phase = '', round = 0
|
||||
const attackCalls = () => [
|
||||
{ name: 'read_file', args: { path: 'seed.txt' } },
|
||||
{ name: 'write_file', args: { path: 'forbidden.txt', content: 'BAD' } },
|
||||
{ name: 'execute_command', args: { command: 'echo BAD > shell-forbidden.txt' } },
|
||||
{ name: mcpName, args: { text: 'BAD' } },
|
||||
{ name: 'todo_write', args: { todos: [{ content: 'Inspect inputs' }] } }
|
||||
]
|
||||
upstream = createServer(async (request, response) => {
|
||||
let raw = ''; for await (const chunk of request) raw += chunk
|
||||
const body = JSON.parse(raw), names = (body.tools ?? []).map(x => x.function.name)
|
||||
requests.push({ phase, path: request.url, tools: names, modeContext: JSON.stringify(body.messages).includes(`mode=\\"${phase.split('-').at(-1)}\\"`) })
|
||||
const calls = round++ === 0 ? attackCalls() : undefined
|
||||
const delta = calls ? { tool_calls: calls.map((c, index) => ({ index, id: 'mode-call-' + index, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.args) } })) } : { content: 'MODE_DONE' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const chunk = d => ({ id: 'mode-fixture', object: 'chat.completion.chunk', created: 1700000000, model: body.model, choices: [{ index: 0, delta: d, finish_reason: null }] })
|
||||
const end = chunk({}); end.choices[0].finish_reason = calls ? 'tool_calls' : 'stop'
|
||||
response.end('data: ' + JSON.stringify(chunk(delta)) + '\n\ndata: ' + JSON.stringify(end) + '\n\ndata: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
||||
const config = { baseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, model: 'mode-fixture', apiKey: '', authMode: 'none', temperature: 0, maxToolRounds: 5 }
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
phase = 'gateway-' + mode; round = 0
|
||||
const result = await api.runTurn({ runId: phase, agentMode: mode, history: [], userText: 'Controlled mode QA', signal: ctx.signal, emit() {}, gateway: new api.Gateway(config), model: config.model, maxToolRounds: 5, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: craftSnapshot })
|
||||
engineResults.push({ phase, tools: result.tools.map(({ name, status, output }) => ({ name, status, output })) })
|
||||
check(phase + ' real Gateway returns terminal', result.content, 'MODE_DONE')
|
||||
check(phase + ' actual tool results enforce mode', result.tools.map(x => x.status), ['ok', 'error', 'error', 'error', mode === 'plan' ? 'ok' : 'error'])
|
||||
}
|
||||
const exe = api.detectOmpPath()
|
||||
assert.ok(exe, 'Actual OMP must be available; no simulated engine PASS')
|
||||
const prepared = await api.prepareOmpModel({ id: 'mode-fixture', config, profile: { id: 'mode-fixture', name: 'Mode fixture', model: config.model, providerId: 'openai', baseUrl: config.baseUrl, authMode: 'none', supportsToolCall: true, supportsImages: false, supportsReasoning: false, maxInputTokens: 32000, maxOutputTokens: 2048, hasApiKey: false, keyStorage: 'none' } })
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
phase = 'omp-' + mode; round = 0
|
||||
kernel = new api.OmpKernel(exe, cwd, prepared.profile, path.join(directory, mode + '.jsonl'), prepared.model, prepared.env)
|
||||
await kernel.start()
|
||||
const result = await api.runOmpTurn({ runId: phase, agentMode: mode, kernel, userText: 'Controlled mode QA', signal: ctx.signal, emit() {}, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: craftSnapshot })
|
||||
engineResults.push({ phase, tools: result.tools.map(({ name, status, output }) => ({ name, status, output })) })
|
||||
check(phase + ' actual OMP terminal', result.status, 'done')
|
||||
check(phase + ' actual OMP final reply', result.content, 'MODE_DONE')
|
||||
check(phase + ' readonly read succeeds', result.tools.some(x => x.name === 'read_file' && x.status === 'ok'))
|
||||
const hidden = result.tools.filter(x => ['write_file', 'execute_command', mcpName].includes(x.name))
|
||||
check(phase + ' all three forbidden attempts are observed', hidden.length, 3)
|
||||
check(phase + ' hidden harmful calls fail', hidden.every(x => x.status === 'error'))
|
||||
check(phase + ' planning tool follows current mode', result.tools.find(x => x.name === 'todo_write')?.status, mode === 'plan' ? 'ok' : 'error')
|
||||
await kernel.dispose(); kernel = undefined
|
||||
}
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
const matching = requests.filter(r => r.phase.endsWith(mode))
|
||||
check(mode + ' tool visibility filtered in both actual engines', matching.every(r => r.tools.includes('read_file') && !r.tools.includes('write_file') && !r.tools.includes('execute_command') && !r.tools.includes(mcpName) && (mode === 'plan' || !r.tools.includes('todo_write'))))
|
||||
check(mode + ' honest mode instruction reaches both actual engines', matching.length >= 4 && matching.every(r => r.modeContext))
|
||||
}
|
||||
check('no hidden write produced a file', await fs.stat(path.join(cwd, 'forbidden.txt')).then(() => true, () => false), false)
|
||||
check('no hidden shell command ran', await fs.stat(path.join(cwd, 'shell-forbidden.txt')).then(() => true, () => false), false)
|
||||
check('neither actual engine reached MCP callTool in restricted mode', (await fs.readFile(path.join(directory, 'mcp-events.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse).filter(x => x.type === 'call').length, 0)
|
||||
check('craft still permits real write', (await api.executeTurnTool('write_file', { path: 'craft.txt', content: 'CRAFT_OK' }, { ...ctx, agentMode: 'craft' }, craftSnapshot)).ok)
|
||||
check('craft SDK MCP retains actual execution', (await api.executeTurnTool(mcpName, { text: 'CRAFT_OK' }, { ...ctx, agentMode: 'craft' }, craftSnapshot)).content, 'stdio:CRAFT_OK')
|
||||
const report = { result: 'PASS', checks, requests, engineResults, directory, limitations: ['Electron transport is controlled IPC registration; real session persistence and tool implementations.', 'Actual OMP executable and actual Gateway hit only a loopback SSE fixture; no remote model or native UI tested.', 'MCP uses an actual SDK stdio fixture. No WorkBuddy/OMP native mode equivalence claimed.'] }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory }))
|
||||
} catch (error) {
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', checks, requests, engineResults, error: String(error), directory }, null, 2)); throw error
|
||||
} finally {
|
||||
await kernel?.dispose(); await api.mcp.disposeMcpServers(); await api.disposeOmpKernels()
|
||||
if (upstream) { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)) }
|
||||
if (previousOmpHome === undefined) delete process.env.YCODE_OMP_HOME; else process.env.YCODE_OMP_HOME = previousOmpHome
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Gateway } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { runOmpTurn, type OmpTurnResult } from '../src/main/agent/omp-turn'
|
||||
import { toolSpecs } from '../src/main/agent/tools'
|
||||
import type { OmpFrame, OmpKernel } from '../src/main/omp'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
type Outcome<T> =
|
||||
| { kind: 'resolved'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| { kind: 'timeout' }
|
||||
|
||||
class FakeOmpKernel extends EventEmitter {
|
||||
prompt(_message: string): Promise<OmpFrame> {
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
|
||||
abort(): Promise<OmpFrame> {
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 500): Promise<Outcome<T>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<Outcome<T>>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs)
|
||||
})
|
||||
const settled = promise.then<Outcome<T>, Outcome<T>>(
|
||||
(value) => ({ kind: 'resolved', value }),
|
||||
(error: unknown) => ({ kind: 'rejected', error })
|
||||
)
|
||||
const outcome = await Promise.race([settled, timeout])
|
||||
if (timer) clearTimeout(timer)
|
||||
return outcome
|
||||
}
|
||||
|
||||
function startTurn(kernel: FakeOmpKernel, signal: AbortSignal, events: ChatEvent[]): Promise<OmpTurnResult> {
|
||||
return runOmpTurn({
|
||||
runId: 'tool-output-regression',
|
||||
kernel: kernel as unknown as OmpKernel,
|
||||
userText: 'test',
|
||||
signal,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
}
|
||||
|
||||
function lastToolResult(events: ChatEvent[], id: string): number {
|
||||
return events.findLastIndex((event) => event.type === 'tool_result' && event.call.id === id)
|
||||
}
|
||||
|
||||
async function testStructuredOutputAndIdentity(): Promise<void> {
|
||||
console.log('\n[1] OMP structured output and tool identity')
|
||||
const specs = toolSpecs()
|
||||
for (const name of ['list_dir', 'read_file']) {
|
||||
const parameters = specs.find((spec) => spec.function.name === name)?.function.parameters as Record<string, unknown> | undefined
|
||||
const properties = parameters?.properties as Record<string, unknown> | undefined
|
||||
const required = parameters?.required as string[] | undefined
|
||||
check(`${name} accepts an optional timeline description`, properties?.description !== undefined && !required?.includes('description'))
|
||||
}
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = { event: kernel.listenerCount('event'), error: kernel.listenerCount('error'), exit: kernel.listenerCount('exit') }
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
const fullText = `${'x'.repeat(720)}AFTER-SUMMARY-SENTINEL`
|
||||
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_start',
|
||||
data: {
|
||||
toolCallId: 'native-call',
|
||||
toolName: 'read_file',
|
||||
args: { path: 'large.txt' },
|
||||
intent: 'Reading project handover status'
|
||||
}
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'native-call',
|
||||
partialResult: { content: [{ type: 'text', text: fullText.slice(0, 650) }] }
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'native-call',
|
||||
result: { content: [{ type: 'text', text: fullText }], isError: false },
|
||||
success: true
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('native text result is retained in full output', record?.output === fullText)
|
||||
check('the summary remains truncated while the sentinel stays in output',
|
||||
record?.summary?.length !== undefined && record.summary.length < fullText.length && record.output?.endsWith('AFTER-SUMMARY-SENTINEL') === true)
|
||||
check('id-only update/end preserve the original tool identity and arguments',
|
||||
record?.id === 'native-call' && record.name === 'read_file' && JSON.stringify(record.args) === JSON.stringify({ path: 'large.txt' }))
|
||||
check('tool intent survives OMP start, progress updates and finalization',
|
||||
record?.intent === 'Reading project handover status' && events.find((event) => event.type === 'tool_call' && event.call.id === 'native-call')?.type === 'tool_call' &&
|
||||
events.find((event) => event.type === 'tool_call' && event.call.id === 'native-call')?.call.intent === 'Reading project handover status')
|
||||
check('successful result status and actual duration are recorded', record?.status === 'ok' && typeof record.durationMs === 'number')
|
||||
check('tool result update precedes the terminal done event', lastToolResult(events, 'native-call') >= 0 &&
|
||||
events.findIndex((event) => event.type === 'done') > lastToolResult(events, 'native-call'))
|
||||
check('OMP listeners return to their initial counts',
|
||||
kernel.listenerCount('event') === baseline.event && kernel.listenerCount('error') === baseline.error && kernel.listenerCount('exit') === baseline.exit)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testUpdateSnapshotsAndEmptyFinalOutput(): Promise<void> {
|
||||
console.log('\n[2] OMP update snapshots and empty final output')
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'progress-call', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'progress-call',
|
||||
partialResult: { content: [{ type: 'text', text: 'phase 1' }] }
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'progress-call',
|
||||
partialResult: { content: [{ type: 'text', text: 'phase 2' }] }
|
||||
})
|
||||
const progress = events.findLast((event) => event.type === 'tool_result' && event.call.id === 'progress-call')
|
||||
check('partialResult updates replace the prior progress snapshot',
|
||||
progress?.type === 'tool_result' && progress.call.output === 'phase 2')
|
||||
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'progress-call',
|
||||
result: { content: [{ type: 'text', text: '' }], isError: false }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('an empty final text result overwrites prior progress text', record?.output === '' && record.summary === '')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testErrorAndCancellationFinalization(): Promise<void> {
|
||||
console.log('\n[3] OMP error and cancellation finalization')
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'failed-call', toolName: 'shell', args: { command: 'x' } })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'failed-call', partialResult: { content: [{ type: 'text', text: 'partial-output' }] } })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'failed-call',
|
||||
result: { content: [{ type: 'text', text: 'final-error-output' }], isError: true },
|
||||
success: true
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('result.isError wins over success=true', record?.status === 'error')
|
||||
check('final text output is retained for an errored tool', record?.output === 'final-error-output')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'kernel-failure', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'kernel-failure', partialResult: 'kept-before-failure' })
|
||||
kernel.emit('error', new Error('kernel failed'))
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = events.findLast((event) => event.type === 'tool_result' && event.call.id === 'kernel-failure')
|
||||
const terminalIndex = events.findIndex((event) => event.type === 'error')
|
||||
check('kernel failure rejects the turn', outcome.kind === 'rejected')
|
||||
check('in-flight tool is finalized as error with partial output', record?.type === 'tool_result' && record.call.status === 'error' && record.call.output === 'kept-before-failure')
|
||||
check('final tool update is emitted before the terminal error', lastToolResult(events, 'kernel-failure') >= 0 && terminalIndex > lastToolResult(events, 'kernel-failure'))
|
||||
check('kernel failure removes all turn listeners', kernel.listenerCount('event') === 0 && kernel.listenerCount('error') === 0 && kernel.listenerCount('exit') === 0)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const controller = new AbortController()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, controller.signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'cancelled-call', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'cancelled-call', partialResult: 'kept-before-cancel' })
|
||||
controller.abort()
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('cancelled turn resolves as cancelled', outcome.kind === 'resolved' && outcome.value.status === 'cancelled')
|
||||
check('in-flight tool is finalized as cancelled with partial output', record?.status === 'cancelled' && record.output === 'kept-before-cancel')
|
||||
check('cancelled tool update precedes cancelled terminal event', lastToolResult(events, 'cancelled-call') >= 0 &&
|
||||
events.findIndex((event) => event.type === 'cancelled') > lastToolResult(events, 'cancelled-call'))
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
async function testNonTextOutput(): Promise<void> {
|
||||
console.log('\n[4] OMP output normalization')
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'image-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'image-call',
|
||||
result: { content: [{ type: 'image', data: 'do-not-serialize-this' }], isError: false }
|
||||
})
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'json-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'json-call',
|
||||
result: { code: 7, message: 'bounded JSON fallback' }
|
||||
})
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'multi-text-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'multi-text-call',
|
||||
result: { content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }] }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const imageRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'image-call') : undefined
|
||||
const jsonRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'json-call') : undefined
|
||||
const multiTextRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'multi-text-call') : undefined
|
||||
check('image-only content is not serialized into tool output', imageRecord?.output === undefined)
|
||||
check('other result objects use JSON text fallback', jsonRecord?.output === '{"code":7,"message":"bounded JSON fallback"}')
|
||||
check('multiple text blocks retain order with newline separators', multiTextRecord?.output === 'first block\nsecond block')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testFallbackLoopOutputAndCancellation(): Promise<void> {
|
||||
console.log('\n[5] fallback loop output and cancellation')
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), 'ycode-tool-output-'))
|
||||
try {
|
||||
const fullText = `${'line\n'.repeat(150)}AFTER-SUMMARY-SENTINEL`
|
||||
await writeFile(join(workspaceRoot, 'large.txt'), fullText, 'utf8')
|
||||
let request = 0
|
||||
const gateway = {
|
||||
async *stream() {
|
||||
request++
|
||||
if (request === 1) {
|
||||
yield { type: 'tool_calls', calls: [{ id: 'fallback-call', function: { name: 'read_file', arguments: '{"path":"large.txt"}' } }] }
|
||||
} else {
|
||||
yield { type: 'text', text: 'finished' }
|
||||
}
|
||||
}
|
||||
} as unknown as Gateway
|
||||
const events: ChatEvent[] = []
|
||||
const result = await runTurn({
|
||||
runId: 'fallback-output',
|
||||
history: [],
|
||||
userText: 'read',
|
||||
signal: new AbortController().signal,
|
||||
emit: (event) => events.push(event),
|
||||
gateway,
|
||||
model: 'test',
|
||||
maxToolRounds: 2,
|
||||
workspaceRoot
|
||||
})
|
||||
check('fallback tool record retains complete result content', result.tools[0]?.output?.endsWith('AFTER-SUMMARY-SENTINEL') === true)
|
||||
|
||||
let abortRequest = 0
|
||||
const abortGateway = {
|
||||
async *stream() {
|
||||
abortRequest++
|
||||
if (abortRequest === 1) {
|
||||
yield { type: 'tool_calls', calls: [{ id: 'fallback-cancel', function: { name: 'read_file', arguments: '{"path":"large.txt"}' } }] }
|
||||
}
|
||||
}
|
||||
} as unknown as Gateway
|
||||
const abortingSignal = { get aborted() { return true } } as AbortSignal
|
||||
const cancelEvents: ChatEvent[] = []
|
||||
const cancelled = await runTurn({
|
||||
runId: 'fallback-cancel',
|
||||
history: [],
|
||||
userText: 'read',
|
||||
signal: abortingSignal,
|
||||
emit: (event) => cancelEvents.push(event),
|
||||
gateway: abortGateway,
|
||||
model: 'test',
|
||||
maxToolRounds: 1,
|
||||
workspaceRoot
|
||||
}).then(() => 'resolved', (error: Error) => error.name)
|
||||
const finalized = cancelEvents.find((event) => event.type === 'tool_result' && event.call.id === 'fallback-cancel')
|
||||
check('pre-aborted fallback cancels without executing the requested tool', cancelled === 'AbortError' &&
|
||||
finalized?.type === 'tool_result' && finalized.call.status === 'cancelled' && finalized.call.output === undefined)
|
||||
check('fallback cancelled tool update precedes terminal event',
|
||||
cancelEvents.findIndex((event) => event.type === 'cancelled') > cancelEvents.findIndex((event) => event.type === 'tool_result' && event.call.id === 'fallback-cancel'))
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await testStructuredOutputAndIdentity()
|
||||
await testUpdateSnapshotsAndEmptyFinalOutput()
|
||||
await testErrorAndCancellationFinalization()
|
||||
await testNonTextOutput()
|
||||
await testFallbackLoopOutputAndCancellation()
|
||||
console.log(failures === 0 ? '\nTool output regression checks passed.\n' : `\n${failures} tool output regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Tool output regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Vendors the pinned OMP kernel binary and its license files into resources/omp.
|
||||
*
|
||||
* The binary is not committed to Git. `npm run omp:vendor` restores it from the
|
||||
* pinned upstream release (or from a local copy via `--from <file|directory>`)
|
||||
* and verifies every asset against resources/omp/runtime.lock.json.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const directory = path.join(project, 'resources', 'omp')
|
||||
const lockPath = path.join(directory, 'runtime.lock.json')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const namePattern = /^[A-Za-z0-9._-]+$/
|
||||
const releasePrefix = 'https://github.com/can1357/oh-my-pi/releases/download/'
|
||||
const rawPrefix = 'https://raw.githubusercontent.com/can1357/oh-my-pi/'
|
||||
const maxAssetBytes = 512 * 1024 * 1024
|
||||
|
||||
function asset(value, label) {
|
||||
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !namePattern.test(value.name)
|
||||
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|
||||
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
|
||||
throw new Error(`Invalid ${label} entry in runtime.lock.json`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function readLock() {
|
||||
const lock = JSON.parse((await fs.readFile(lockPath, 'utf8')).replace(/^\uFEFF/, ''))
|
||||
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|
||||
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|
||||
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|
||||
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
|
||||
throw new Error('Invalid OMP runtime lock')
|
||||
}
|
||||
const binary = asset(lock.binary, 'binary')
|
||||
const license = asset(lock.license, 'license')
|
||||
const thirdPartyNotices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
|
||||
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || thirdPartyNotices.name !== 'THIRD-PARTY-NOTICES.txt') {
|
||||
throw new Error('OMP runtime lock uses unexpected asset names')
|
||||
}
|
||||
return { lock, binary, license, thirdPartyNotices }
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function verifyFile(file, spec) {
|
||||
const stat = await fs.stat(file).catch(() => null)
|
||||
if (!stat?.isFile()) throw new Error(`Missing OMP asset: ${path.relative(project, file)}`)
|
||||
if (stat.size !== spec.bytes) throw new Error(`OMP asset size mismatch: ${spec.name} (${stat.size} != ${spec.bytes})`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) throw new Error(`OMP asset digest mismatch: ${spec.name}`)
|
||||
return digest
|
||||
}
|
||||
|
||||
async function copyVerified(source, target, spec) {
|
||||
if (path.resolve(source) === path.resolve(target)) return verifyFile(target, spec)
|
||||
const stat = await fs.stat(source)
|
||||
const file = stat.isDirectory() ? path.join(source, spec.name) : source
|
||||
const sourceStat = await fs.stat(file)
|
||||
if (!sourceStat.isFile() || sourceStat.size !== spec.bytes) throw new Error(`Local OMP asset has an unexpected size: ${file}`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) throw new Error(`Local OMP asset does not match the pinned release: ${file}`)
|
||||
const temporary = `${target}.part`
|
||||
await fs.copyFile(file, temporary)
|
||||
try {
|
||||
await verifyFile(temporary, spec)
|
||||
await fs.rename(temporary, target)
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
async function download(url, target, spec) {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { 'user-agent': 'YCode OMP vendor' },
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000)
|
||||
})
|
||||
if (!response.ok || !response.body) throw new Error(`OMP download failed (${response.status}): ${url}`)
|
||||
const declared = response.headers.get('content-length')
|
||||
if (declared && Number(declared) !== spec.bytes) throw new Error(`OMP download size header mismatch: ${spec.name}`)
|
||||
const temporary = `${target}.part`
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary))
|
||||
const digest = await verifyFile(temporary, spec)
|
||||
await fs.rename(temporary, target)
|
||||
return digest
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function releaseUrl(version, name) {
|
||||
const url = `${releasePrefix}v${version}/${name}`
|
||||
if (!url.startsWith(releasePrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP release URL: ${url}`)
|
||||
return url
|
||||
}
|
||||
|
||||
function rawUrl(version, name) {
|
||||
const url = `${rawPrefix}v${version}/${name}`
|
||||
if (!url.startsWith(rawPrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP raw URL: ${url}`)
|
||||
return url
|
||||
}
|
||||
|
||||
async function ensureAsset({ lock, spec, source, target, verifyOnly, offline }) {
|
||||
if (await fs.stat(target).catch(() => null)) {
|
||||
const digest = await verifyFile(target, spec)
|
||||
console.log(`[omp] ${spec.name} verified (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
return
|
||||
}
|
||||
if (verifyOnly) throw new Error(`Missing OMP asset: ${path.relative(project, target)}`)
|
||||
if (source) {
|
||||
if (spec.name !== 'omp.exe') throw new Error(`--from only applies to omp.exe, not ${spec.name}`)
|
||||
const digest = await copyVerified(source, target, spec)
|
||||
console.log(`[omp] ${spec.name} imported from ${source} (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
return
|
||||
}
|
||||
if (offline) throw new Error(`OMP asset is missing in offline mode: ${path.relative(project, target)}`)
|
||||
try {
|
||||
const digest = await download(releaseUrl(lock.version, spec.name), target, spec)
|
||||
console.log(`[omp] ${spec.name} downloaded (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
} catch (releaseError) {
|
||||
if (spec.name === 'omp.exe') throw releaseError
|
||||
const digest = await download(rawUrl(lock.version, spec.name), target, spec)
|
||||
console.log(`[omp] ${spec.name} downloaded from raw source (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const verifyOnly = args.includes('--verify')
|
||||
const offline = args.includes('--offline')
|
||||
const fromIndex = args.indexOf('--from')
|
||||
const source = fromIndex >= 0 ? args[fromIndex + 1] : null
|
||||
if (fromIndex >= 0 && (!source || source.startsWith('--'))) throw new Error('--from requires a file or directory')
|
||||
|
||||
const { lock, binary, license, thirdPartyNotices } = await readLock()
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
console.log(`[omp] pinned ${lock.upstream} v${lock.version} (${lock.platform})`)
|
||||
|
||||
await ensureAsset({ lock, spec: binary, source, target: path.join(directory, binary.name), verifyOnly, offline })
|
||||
await ensureAsset({ lock, spec: license, target: path.join(directory, license.name), verifyOnly, offline })
|
||||
await ensureAsset({ lock, spec: thirdPartyNotices, target: path.join(directory, thirdPartyNotices.name), verifyOnly, offline })
|
||||
|
||||
console.log(`[omp] ready: ${path.relative(project, directory)}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[omp] ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const lockPath = path.join(project, 'resources/sandbox/git-runtime.lock.json')
|
||||
const defaults = path.join(project, 'resources/sandbox/win32-x64/git')
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
const within = (root, target) => { const rel = path.relative(root, target); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel) }
|
||||
const safePath = name => typeof name === 'string' && name.length > 0 && name.length <= 2048 && !path.isAbsolute(name) && !/[\\:\x00-\x1f]/.test(name)
|
||||
&& !name.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
|
||||
async function regular(file, maximum = 128 * 1024 * 1024) {
|
||||
const stat = await fs.lstat(file)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error(`Unsafe bundled Git file: ${file}`)
|
||||
const bytes = await fs.readFile(file)
|
||||
if (bytes.length !== stat.size) throw new Error('Bundled Git file changed during reading')
|
||||
return bytes
|
||||
}
|
||||
async function directory(file) {
|
||||
const stat = await fs.lstat(file)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Bundled Git directory is a link or special file: ${file}`)
|
||||
}
|
||||
export async function readGitRuntimeLock() {
|
||||
const lock = JSON.parse((await regular(lockPath, 64 * 1024)).toString('utf8'))
|
||||
if (lock.format !== 1 || lock.provider !== 'git-for-windows' || lock.platform !== 'win32-x64' || !/^\d+\.\d+\.\d+\.windows\.\d+$/.test(lock.version)
|
||||
|| !hashPattern.test(lock.runtimeManifestSha256) || !hashPattern.test(lock.archive?.sha256) || !Number.isSafeInteger(lock.archive.bytes) || lock.archive.bytes < 1 || lock.archive.bytes > 128 * 1024 * 1024
|
||||
|| new URL(lock.archive.url).origin !== 'https://github.com' || !lock.archive.url.startsWith('https://github.com/git-for-windows/git/releases/download/')
|
||||
|| lock.archive.name !== path.basename(lock.archive.url) || !Number.isSafeInteger(Date.parse(lock.publishedAt))) throw new Error('Invalid pinned Git runtime lock')
|
||||
return lock
|
||||
}
|
||||
export async function verifyBundledGit(outputDirectory = defaults) {
|
||||
const lock = await readGitRuntimeLock()
|
||||
await directory(outputDirectory); await directory(path.join(outputDirectory, 'files'))
|
||||
const bytes = await regular(path.join(outputDirectory, 'manifest.json'), 4 * 1024 * 1024), manifest = JSON.parse(bytes.toString('utf8'))
|
||||
if (sha256(bytes) !== lock.runtimeManifestSha256) throw new Error('Bundled Git manifest digest mismatch against release lock')
|
||||
if (manifest.version !== 1 || manifest.kind !== 'git' || !uuidPattern.test(manifest.id) || manifest.distribution?.version !== lock.version
|
||||
|| manifest.distribution.archiveSha256 !== lock.archive.sha256 || manifest.distribution.releaseUrl !== lock.releaseUrl
|
||||
|| !Array.isArray(manifest.files) || manifest.files.length < 7 || manifest.files.length > 12_000) throw new Error('Bundled Git does not match the pinned release')
|
||||
const filesRoot = path.join(outputDirectory, 'files'), checked = new Set([filesRoot]), seen = new Set()
|
||||
let total = 0
|
||||
for (const file of manifest.files) {
|
||||
if (!safePath(file.path) || seen.has(file.path.toLowerCase()) || !hashPattern.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 128 * 1024 * 1024) throw new Error('Invalid bundled Git file manifest')
|
||||
seen.add(file.path.toLowerCase())
|
||||
let parent = path.dirname(path.join(filesRoot, file.path))
|
||||
while (!checked.has(parent)) { if (!within(filesRoot, parent)) throw new Error('Bundled Git path escaped'); await directory(parent); checked.add(parent); parent = path.dirname(parent) }
|
||||
const content = await regular(path.join(filesRoot, file.path))
|
||||
if (content.length !== file.bytes || sha256(content) !== file.sha256) throw new Error(`Bundled Git digest mismatch: ${file.path}`)
|
||||
total += content.length
|
||||
}
|
||||
if (total !== manifest.bytes || total > 512 * 1024 * 1024 || !['git.exe', 'license.txt', 'third_party_notices.txt', 'sources.json', 'package-versions.txt'].every(name => seen.has(name))) throw new Error('Bundled Git license inventory or size is invalid')
|
||||
return { version: lock.version, archiveSha256: lock.archive.sha256, manifestSha256: sha256(bytes), files: manifest.files.length, bytes: total }
|
||||
}
|
||||
|
||||
export async function vendorSandboxGit({ archivePath, outputDirectory = defaults, offline = false, rebuild = false } = {}) {
|
||||
const lock = await readGitRuntimeLock(), target = path.resolve(outputDirectory)
|
||||
if (path.basename(target) !== 'git' || !within(project, target) || target === project) throw new Error('Git output must be a project-local generated git directory')
|
||||
if (await fs.lstat(target).catch(() => null)) {
|
||||
await directory(target)
|
||||
const previous = await regular(path.join(target, 'manifest.json'), 4 * 1024 * 1024).then(bytes => JSON.parse(bytes.toString('utf8'))).catch(() => null)
|
||||
if (previous?.version !== 1 || previous.kind !== 'git' || !hashPattern.test(previous.distribution?.archiveSha256)) throw new Error('Refusing to replace an existing directory that is not a generated Git bundle')
|
||||
}
|
||||
if (!rebuild && await fs.stat(path.join(target, 'manifest.json')).catch(() => null)) {
|
||||
try { const current = await verifyBundledGit(target); console.log(`SANDBOX_GIT_BUNDLE_READY ${current.version} ${current.bytes}`); return current } catch { /* Rebuild explicitly requested vendored resources from the pinned archive. */ }
|
||||
}
|
||||
const cache = path.join(project, '.tmp/vendor-git')
|
||||
await fs.mkdir(cache, { recursive: true })
|
||||
const archive = archivePath ? path.resolve(archivePath) : path.join(cache, lock.archive.name)
|
||||
if (!await fs.lstat(archive).catch(() => null)) {
|
||||
if (archivePath || offline) throw new Error('Pinned MinGit archive is missing; provide --archive or allow this explicit vendor command to download it')
|
||||
const response = await fetch(lock.archive.url, { signal: AbortSignal.timeout(180_000) })
|
||||
if (!response.ok || Number(response.headers.get('content-length') || 0) > lock.archive.bytes) throw new Error(`MinGit download failed: ${response.status}`)
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length !== lock.archive.bytes || sha256(bytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
|
||||
await fs.writeFile(archive, bytes, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
const archiveBytes = await regular(archive)
|
||||
if (archiveBytes.length !== lock.archive.bytes || sha256(archiveBytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
|
||||
const unpacked = unzipSync(archiveBytes, { filter: file => /^(?:ucrt64|mingw64)\/bin\/(?:git(?:-remote-https?|-upload-pack|-receive-pack)?\.exe|[^/]+\.dll)$/i.test(file.name)
|
||||
|| /^usr\/bin\/(?:(?:sh|ssh)\.exe|[^/]+\.dll)$/i.test(file.name)
|
||||
|| /^usr\/share\/licenses\//.test(file.name)
|
||||
|| /^(?:ucrt64|mingw64)\/share\/licenses\//.test(file.name) || ['LICENSE.txt', 'etc/package-versions.txt'].includes(file.name) })
|
||||
const work = await fs.mkdtemp(path.join(project, '.tmp/vendor-git-build-'))
|
||||
const extraction = path.join(work, 'archive'), stateRoot = path.join(work, 'state'), bundle = path.join(work, 'bundle')
|
||||
for (const [name, bytes] of Object.entries(unpacked)) {
|
||||
if (name.endsWith('/')) continue
|
||||
if (!safePath(name) || bytes.length > 128 * 1024 * 1024) throw new Error('Unsafe MinGit archive entry')
|
||||
const destination = path.join(extraction, name)
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
const prefix = unpacked['ucrt64/bin/git.exe'] ? 'ucrt64' : 'mingw64'
|
||||
const loaderPath = path.join(work, 'loader.mjs')
|
||||
await build({ absWorkingDir: project, stdin: { contents: "export { importSandboxToolchain } from './src/main/sandbox/toolchains'; export { configureSandbox } from './src/main/sandbox/configuration';", resolveDir: project, sourcefile: 'git-vendor-loader.ts', loader: 'ts' }, bundle: true, platform: 'node', format: 'esm', outfile: loaderPath, logLevel: 'warning' })
|
||||
const loader = await import(pathToFileURL(loaderPath).href)
|
||||
loader.configureSandbox({ stateRoot, resourceDirectory: path.join(work, 'resources'), useBundledGit: false })
|
||||
const imported = await loader.importSandboxToolchain('git', path.join(extraction, prefix, 'bin/git.exe'))
|
||||
const importedObject = path.join(stateRoot, 'toolchains/objects', imported.id)
|
||||
await fs.cp(importedObject, bundle, { recursive: true, errorOnExist: true, force: false })
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(bundle, 'manifest.json'), 'utf8'))
|
||||
const put = async (name, bytes) => {
|
||||
if (!safePath(name)) throw new Error('Invalid third-party notice path')
|
||||
const destination = path.join(bundle, 'files', name)
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
manifest.files.push({ path: name, bytes: bytes.length, sha256: sha256(bytes) }); manifest.bytes += bytes.length
|
||||
}
|
||||
for (const [name, bytes] of Object.entries(unpacked)) {
|
||||
if (name.endsWith('/')) continue
|
||||
if (name === 'LICENSE.txt') await put('LICENSE.txt', bytes)
|
||||
else if (name === 'etc/package-versions.txt') await put('package-versions.txt', bytes)
|
||||
else if (/^(?:ucrt64|mingw64)\/share\/licenses\//.test(name)) await put(name.replace(/^(?:ucrt64|mingw64)\/share\//, ''), bytes)
|
||||
else if (/^usr\/share\/licenses\//.test(name)) await put(name.replace(/^usr\/share\//, 'usr/'), bytes)
|
||||
}
|
||||
await put('SOURCES.json', Buffer.from(JSON.stringify({ provider: lock.provider, version: lock.version, releaseUrl: lock.releaseUrl, archive: lock.archive, sources: lock.sources, components: manifest.files.filter(file => /\.(?:exe|dll)$/i.test(file.path)).map(file => file.path) }, null, 2) + '\n'))
|
||||
await put('THIRD_PARTY_NOTICES.txt', Buffer.from(`YCode bundled Git runtime\n\nUnmodified Git for Windows ${lock.version} and its required DLLs, extracted from the official MinGit distribution.\nRelease: ${lock.releaseUrl}\nArchive SHA-256: ${lock.archive.sha256}\n\nGit is licensed under GPL-2.0; see LICENSE.txt. Dependency license/copyright notices from the upstream archive are preserved under licenses/. Some notices refer to upstream components not included in this runtime subset. See SOURCES.json and package-versions.txt for provenance and source/build references.\n\nGit executes as a separate process. No host Git configuration or credentials are bundled.\n`))
|
||||
const idBytes = Buffer.from(lock.archive.sha256.slice(0, 32), 'hex'); idBytes[6] = (idBytes[6] & 15) | 64; idBytes[8] = (idBytes[8] & 63) | 128
|
||||
const hex = idBytes.toString('hex')
|
||||
manifest.id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
manifest.createdAt = Date.parse(lock.publishedAt)
|
||||
manifest.distribution = { version: lock.version, archiveSha256: lock.archive.sha256, releaseUrl: lock.releaseUrl }
|
||||
manifest.features.push('bundled')
|
||||
manifest.files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
|
||||
const manifestBytes = Buffer.from(JSON.stringify(manifest) + '\n')
|
||||
await fs.writeFile(path.join(bundle, 'manifest.json'), manifestBytes)
|
||||
if (sha256(manifestBytes) !== lock.runtimeManifestSha256) throw new Error(`Generated Git manifest needs review: ${path.join(bundle, 'manifest.json')} SHA-256 ${sha256(manifestBytes)}. Update the reviewed source lock before publishing resources.`)
|
||||
const verified = await verifyBundledGit(bundle)
|
||||
const parent = path.dirname(target)
|
||||
await fs.mkdir(parent, { recursive: true })
|
||||
if (!within(await fs.realpath(project), await fs.realpath(parent))) throw new Error('Git bundle output parent escaped the project')
|
||||
const current = await fs.lstat(target).catch(() => null), backup = path.join(work, 'previous-bundle')
|
||||
if (current) {
|
||||
await directory(target)
|
||||
if (path.dirname(await fs.realpath(target)) !== await fs.realpath(parent)) throw new Error('Git bundle replacement target escaped its parent')
|
||||
await fs.rename(target, backup)
|
||||
}
|
||||
try { await fs.rename(bundle, target) } catch (error) { if (current) await fs.rename(backup, target); throw error }
|
||||
console.log(`SANDBOX_GIT_BUNDLE_PASS ${verified.version} ${verified.files} files ${verified.bytes} bytes`)
|
||||
return verified
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2), options = {}
|
||||
while (args.length) {
|
||||
const name = args.shift()
|
||||
if (name === '--offline') options.offline = true
|
||||
else if (name === '--rebuild') options.rebuild = true
|
||||
else if (name === '--archive' || name === '--output') { const value = args.shift(); if (!value) throw new Error(`Missing ${name}`); options[name === '--archive' ? 'archivePath' : 'outputDirectory'] = value }
|
||||
else throw new Error(`Unknown option: ${name}`)
|
||||
}
|
||||
await vendorSandboxGit(options)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -25,8 +25,7 @@ if (!process.versions.electron) {
|
||||
{ id: 'account-menu-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'account-menu-run', tools: [], parts: [] }
|
||||
] }]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -16,7 +16,6 @@ if (!process.versions.electron) {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_DISABLE_GPU: '1'
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
|
||||
@@ -29,7 +29,7 @@ if (!process.versions.electron) {
|
||||
const home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -63,7 +63,6 @@ if (!process.versions.electron) {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_QA_OFFSCREEN: '1'
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Verifies the staged (or packaged) OMP kernel assets: lock schema, sizes,
|
||||
* SHA-256 digests, and that the binary actually launches with the pinned version.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-bundled-omp.cjs # resources/omp
|
||||
* node scripts/verify-bundled-omp.cjs --packaged # release/win-unpacked/resources/omp
|
||||
* node scripts/verify-bundled-omp.cjs <directory>
|
||||
*/
|
||||
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createReadStream, existsSync, promises: fs } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const project = path.resolve(__dirname, '..')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const lockName = 'runtime.lock.json'
|
||||
const maxAssetBytes = 512 * 1024 * 1024
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
function asset(value, label) {
|
||||
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value.name)
|
||||
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|
||||
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
|
||||
fail(`Invalid ${label} entry in ${lockName}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function verifyAsset(directory, spec) {
|
||||
const file = path.join(directory, spec.name)
|
||||
const stat = await fs.stat(file).catch(() => null)
|
||||
if (!stat?.isFile()) fail(`Missing asset: ${file}`)
|
||||
if (stat.size !== spec.bytes) fail(`Size mismatch for ${spec.name}: ${stat.size} != ${spec.bytes}`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) fail(`SHA-256 mismatch for ${spec.name}`)
|
||||
console.log(`PASS ${spec.name} ${spec.bytes} bytes ${digest.slice(0, 12)}…`)
|
||||
}
|
||||
|
||||
function readVersion(exe, timeoutMs = 30_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(exe, ['--version'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
reject(new Error(`Timed out launching ${exe}`))
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk })
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) reject(new Error(`OMP exited with ${code}: ${stderr.trim()}`))
|
||||
else resolve(stdout.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const packaged = args.includes('--packaged')
|
||||
const positional = args.find((arg) => !arg.startsWith('--'))
|
||||
const directory = positional
|
||||
? path.resolve(positional)
|
||||
: packaged
|
||||
? path.join(project, 'release', 'win-unpacked', 'resources', 'omp')
|
||||
: path.join(project, 'resources', 'omp')
|
||||
|
||||
if (!existsSync(directory)) fail(`OMP directory does not exist: ${directory}`)
|
||||
|
||||
const lock = JSON.parse((await fs.readFile(path.join(directory, lockName), 'utf8')).replace(/^\uFEFF/, ''))
|
||||
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|
||||
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|
||||
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|
||||
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
|
||||
fail(`Invalid ${lockName}`)
|
||||
}
|
||||
const binary = asset(lock.binary, 'binary')
|
||||
const license = asset(lock.license, 'license')
|
||||
const notices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
|
||||
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || notices.name !== 'THIRD-PARTY-NOTICES.txt') {
|
||||
fail(`Unexpected asset names in ${lockName}`)
|
||||
}
|
||||
|
||||
console.log(`Checking OMP ${lock.version} in ${directory}`)
|
||||
await verifyAsset(directory, binary)
|
||||
await verifyAsset(directory, license)
|
||||
await verifyAsset(directory, notices)
|
||||
|
||||
const output = await readVersion(path.join(directory, binary.name))
|
||||
if (output !== `omp/${lock.version}`) fail(`Unexpected OMP version output: ${output}`)
|
||||
console.log(`PASS omp.exe --version => ${output}`)
|
||||
console.log(`OMP ${lock.version} bundle is valid.`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`FAIL ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -4,7 +4,7 @@ if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-center-'))
|
||||
await Promise.all(['data', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '0' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '0' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''; child.stdout.on('data', b => { log += b; if (b.toString().includes('CAPABILITY_QA')) process.stdout.write(b) }); child.stderr.on('data', b => { log += b })
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// Isolated production renderer/preload/IPC -> actual OMP -> controlled upstream + actual SDK MCP.
|
||||
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
if (!process.versions.electron) {
|
||||
const { spawn } = require('node:child_process')
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-flows-'))
|
||||
await Promise.all(['data', 'home', 'source-skill', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
await fs.writeFile(path.join(directory, 'source-skill', 'SKILL.md'), '---\nname: capability-acceptance\ndescription: Controlled capability integration skill\nversion: 1.0\n---\n\nCAPABILITY_SKILL_BODY_MARKER\nUse the configured echo connector when requested.\n')
|
||||
await fs.writeFile(path.join(directory, 'source-skill', 'reference.txt'), 'CAPABILITY_RESOURCE_MARKER')
|
||||
const omp = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA, 'Ygent', 'omp.exe')
|
||||
assert.ok((await fs.stat(omp)).isFile())
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_OMP_PATH: omp, YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_MODEL_API_KEY
|
||||
for (const stage of ['run', 'restart']) {
|
||||
const code = await new Promise(resolve => {
|
||||
const child = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env: { ...env, YCODE_QA_STAGE: stage }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('CAPABILITY_')) process.stdout.write(bytes) }); child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 160000)
|
||||
child.once('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, stage + '-process.log'), output); resolve(code ?? 1) })
|
||||
})
|
||||
const failedReport = await fs.stat(path.join(directory, stage + '-failure.json')).then(() => true, () => false)
|
||||
const report = await fs.readFile(path.join(directory, 'report.json'), 'utf8').then(JSON.parse, () => null)
|
||||
if (code || failedReport || report?.result !== 'PASS') { console.log(JSON.stringify({ directory, stage, result: 'FAIL' })); process.exitCode = code || 1; return }
|
||||
}
|
||||
console.log(JSON.stringify({ directory, result: 'PASS' }))
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, dialog } = require('electron'), { createServer } = require('node:http')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-flows-'))
|
||||
app.disableHardwareAcceleration(); app.setPath('userData', path.join(directory, 'data')); app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = [], requests = [], stage = process.env.YCODE_QA_STAGE
|
||||
let main, upstream, phase = 'boot'
|
||||
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push({ label, pass: true }) }
|
||||
async function read(expression) { try { return await main.webContents.executeJavaScript(expression) } catch (error) { throw Error(String(error) + ' :: ' + expression.slice(0, 340)) } }
|
||||
async function wait(expression, label, timeout = 30000) { const start = Date.now(); while (Date.now() - start < timeout) { const value = await read(expression); if (value) return value; await pause(40) } throw Error('Timeout: ' + label) }
|
||||
async function click(selector) { await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing enabled control '+${JSON.stringify(selector)});e.click()})()`); await pause(60) }
|
||||
async function clickText(selector, text, exact = true) { await read(`(()=>{const text=${JSON.stringify(text)};const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>${exact ? 'e.textContent.trim()===text' : 'e.textContent.includes(text)'});if(!e||e.disabled)throw Error('Missing enabled text '+text);e.click()})()`); await pause(60) }
|
||||
async function fill(selector, value) { await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing input');const p=e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(p,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}))})()`); await pause(50) }
|
||||
async function hoverAddMenuItem(id){const selector='.cr-add-menu__popover [data-add-menu-id="'+id+'"]';await wait('!!document.querySelector('+JSON.stringify(selector)+')','source add menu row');const point=await read('(()=>{const b=document.querySelector('+JSON.stringify(selector)+').getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');const trigger=await read('(()=>{const b=document.querySelector(".wb-composer__add-button").getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...trigger});await pause(25);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(220)}
|
||||
async function shot(name) { await read('document.fonts.ready.then(()=>true)'); await pause(120); const image = await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }); await fs.writeFile(path.join(directory, name + '.png'), image.toPNG()) }
|
||||
async function step(name) { phase = name; await fs.writeFile(path.join(directory, stage + '-phase.txt'), name) }
|
||||
async function boot() {
|
||||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path.join(directory, 'source-skill')] }) // Only OS picker selection is controlled; production installer and IPC run unchanged.
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
|
||||
for (let i = 0; i < 300; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(30) }
|
||||
assert.ok(main); main.setContentSize(1336, 901)
|
||||
await wait('!!window.ycode && !!document.querySelector(".wb-composer__input")', 'production UI')
|
||||
check('isolated production userData', path.resolve((await read('window.ycode.app.info()')).configDir), path.join(directory, 'data'))
|
||||
check('actual OMP available', (await read('window.ycode.app.info()')).omp.available)
|
||||
}
|
||||
async function openCenter(tab) {
|
||||
await clickText('.sidebar__tab', '专家·技能·连接器')
|
||||
await wait('!!document.querySelector(".yc-capability-center")', 'capability center')
|
||||
if (tab) await clickText('.um-tab', tab)
|
||||
await pause(100)
|
||||
}
|
||||
function completion(response, model, content, toolCall) {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const chunk = delta => ({ id: 'capability-completion', object: 'chat.completion.chunk', created: 1700000000, model, choices: [{ index: 0, delta, finish_reason: null }] })
|
||||
response.write('data: ' + JSON.stringify(chunk({ role: 'assistant', ...(toolCall ? { tool_calls: [{ index: 0, id: 'cap-call-' + requests.length, type: 'function', function: { name: toolCall.name, arguments: JSON.stringify(toolCall.args) } }] } : { content }) })) + '\n\n')
|
||||
const final = chunk({}); final.choices[0].finish_reason = toolCall ? 'tool_calls' : 'stop'; response.write('data: ' + JSON.stringify(final) + '\n\ndata: [DONE]\n\n'); response.end()
|
||||
}
|
||||
async function cleanup() { if (upstream) { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)) } }
|
||||
;(async () => {
|
||||
await boot()
|
||||
if (stage === 'restart') {
|
||||
await step('restart')
|
||||
const previous = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8'))
|
||||
const skills = await read('window.ycode.skills.list()'), servers = await wait('window.ycode.mcp.list().then(s=>s.some(x=>x.status==="connected")&&s)', 'startup MCP reconnect')
|
||||
check('skill persists after new Electron process', skills.some(item => item.id === previous.skill.id && item.enabled))
|
||||
check('MCP reconnects and discovers tools after restart', servers.some(item => item.id === previous.server.id && item.status === 'connected' && item.tools.length === 4))
|
||||
const history = await read(`window.ycode.session.messages(${JSON.stringify(previous.sessionId)})`)
|
||||
check('skill resource link persists', history[0].skills[0].uri, 'skill://capability-acceptance')
|
||||
check('real connector result persists', history[1].tools.some(tool => tool.output.includes('stdio:CAPABILITY_ECHO_INPUT')))
|
||||
previous.restartChecks = checks; previous.result = 'PASS'; await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(previous, null, 2))
|
||||
console.log('CAPABILITY_RESTART_PASS ' + directory); app.quit(); return
|
||||
}
|
||||
upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw), text = JSON.stringify(body.messages), names = (body.tools ?? []).map(tool => tool.function.name)
|
||||
requests.push({ stream: body.stream, selectedSkillBody: text.includes('CAPABILITY_SKILL_BODY_MARKER'), toolNames: names, hasEchoResult: text.includes('stdio:CAPABILITY_ECHO_INPUT'), hasResourceResult: text.includes('CAPABILITY_RESOURCE_MARKER') })
|
||||
if (!body.stream) { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ id: 'test', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }] })); return }
|
||||
const lastUser = body.messages.findLast(message => message.role === 'user')?.content ?? ''
|
||||
if (JSON.stringify(lastUser).includes('CAPABILITY_AUTO_LOAD')) {
|
||||
const hasLoadResult = body.messages.some(message => message.role === 'tool' && String(message.content).includes('CAPABILITY_SKILL_BODY_MARKER'))
|
||||
if (!hasLoadResult) { const match = text.match(/user:[a-f0-9]{24}/); completion(response, body.model, '', { name: 'ycode_load_skill', args: { id: match?.[0] } }); return }
|
||||
completion(response, body.model, 'CAPABILITY_AUTO_OK'); return
|
||||
}
|
||||
const hasEchoResult = body.messages.some(message => message.role === 'tool' && String(message.content).includes('stdio:CAPABILITY_ECHO_INPUT'))
|
||||
if (!hasEchoResult) { const name = names.find(name => name.startsWith('mcp_echo_')); assert.ok(name); completion(response, body.model, '', { name, args: { text: 'CAPABILITY_ECHO_INPUT' } }); return }
|
||||
completion(response, body.model, 'CAPABILITY_OK')
|
||||
} catch (error) { response.writeHead(500); response.end('Controlled upstream failure'); requests.push({ error: String(error) }) }
|
||||
})
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
||||
const profile = { id: 'capability-model', name: 'Capability Fixture', providerId: 'custom', model: 'capability-fixture', baseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, apiKey: '', authMode: 'none', supportsToolCall: true, supportsImages: false, maxInputTokens: 32000, maxOutputTokens: 1000 }
|
||||
await read(`window.ycode.models.save(${JSON.stringify(profile)})`); await read('window.ycode.models.select("capability-model")')
|
||||
const space = await read(`window.ycode.workspace.attach(${JSON.stringify(path.join(directory, 'workspace'))}).then(v=>v.workspaces.find(x=>x.id===v.attachedWorkspaceId))`)
|
||||
check('space registered', !!space)
|
||||
main.webContents.reload(); await pause(500); await wait('!!document.querySelector(".wb-composer__input")', 'reloaded controlled models')
|
||||
await fill('.wb-composer__input', 'DRAFT_SURVIVES_CAPABILITY_CENTER')
|
||||
await step('center-draft-and-source-layout'); await openCenter()
|
||||
await shot('experts-market')
|
||||
const bounds = await read(`(()=>{const r=document.querySelector('.yc-capability-center').getBoundingClientRect(),h=document.querySelector('.yc-capability-center header').getBoundingClientRect();return{page:[r.x,r.y,r.width,r.height],header:[h.x,h.y,h.width,h.height]}})()`)
|
||||
await fs.writeFile(path.join(directory, 'layout.json'), JSON.stringify(bounds, null, 2))
|
||||
check('source topbar height', bounds.header[3], 64)
|
||||
await clickText('button', '我的专家'); await shot('my-experts-empty')
|
||||
await clickText('.sidebar__tab', '新建任务')
|
||||
check('draft survives source center roundtrip', await read('document.querySelector(".wb-composer__input").value'), 'DRAFT_SURVIVES_CAPABILITY_CENTER')
|
||||
await openCenter('技能')
|
||||
await shot('skills-market')
|
||||
await step('real-skill-install')
|
||||
// The UI exposes source add -> upload -> choose folder. Use stable accessible labels.
|
||||
await click('[aria-label="添加技能"]'); await clickText('button,[role="menuitem"]', '上传技能')
|
||||
await click('[aria-label="选择文件夹"]')
|
||||
await wait('window.ycode.skills.list().then(s=>s.length===1)', 'real folder installer')
|
||||
const skill = (await read('window.ycode.skills.list()'))[0]
|
||||
check('source folder retained', await fs.readFile(path.join(directory, 'source-skill', 'reference.txt'), 'utf8'), 'CAPABILITY_RESOURCE_MARKER')
|
||||
if (await read('!!document.querySelector(".sm-import-modal")')) await click('[aria-label="关闭导入技能"]')
|
||||
await wait(`!document.querySelector('[aria-label="导入技能"]')`, 'import dialog closes after actual install')
|
||||
if (await read(`!!document.querySelector('[aria-label="已安装技能"]')`)) await click('[aria-label="已安装技能"]')
|
||||
await shot('skills-installed')
|
||||
await step('real-mcp-configuration')
|
||||
if (!(await read('!!document.querySelector(".um-tab")'))) await click('[aria-label="返回专家中心"]')
|
||||
await clickText('.um-tab', '连接器'); await clickText('button', '自定义连接器')
|
||||
await shot('mcp-empty')
|
||||
await clickText('button', '添加 MCP', false)
|
||||
const mcpConfig = { mcpServers: { 'capability-echo': { command: process.env.YCODE_QA_NODE, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] } } }
|
||||
await wait('!!document.querySelector(".mcp-config-textarea,.mcp-config-editor-monaco-wrapper .monaco-editor textarea")', 'real JSON editor')
|
||||
if (await read('!!document.querySelector(".mcp-config-textarea")')) await fill('.mcp-config-textarea', JSON.stringify(mcpConfig, null, 2))
|
||||
else {
|
||||
await pause(500)
|
||||
main.webContents.focus()
|
||||
const point = await read(`(()=>{const r=document.querySelector('.mcp-config-editor-monaco-wrapper .view-lines').getBoundingClientRect();return{x:Math.round(r.left+80),y:Math.round(r.top+10)}})()`)
|
||||
main.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 }); main.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 }); await pause(60)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] }); main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await read(`(()=>{const transfer=new DataTransfer();transfer.setData('text/plain',${JSON.stringify(JSON.stringify(mcpConfig, null, 2))});document.activeElement.dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));return true})()`)
|
||||
await pause(100)
|
||||
}
|
||||
await clickText('button', '保存')
|
||||
await wait('window.ycode.mcp.list().then(s=>s.length===1)', 'real MCP registration')
|
||||
// Trust/enable through the real confirmation UI, following source configuration hash semantics.
|
||||
const server = (await read('window.ycode.mcp.list()'))[0]
|
||||
if (!server.trusted) { await clickText('button', '批准配置', false) }
|
||||
await wait('window.ycode.mcp.list().then(s=>s[0]?.trusted)', 'config trust')
|
||||
if (!(await read('window.ycode.mcp.list().then(s=>s[0].enabled)'))) await click('[role="switch"][aria-label*="capability-echo"]')
|
||||
await wait('window.ycode.mcp.list().then(s=>s[0]?.status==="connected"&&s[0].tools.length===4)', 'actual SDK handshake and two tool pages')
|
||||
await shot('mcp-connected')
|
||||
await click('.mcp-modal-close')
|
||||
await clickText('.sidebar__tab', '新建任务')
|
||||
await fill('.wb-composer__input', 'CAPABILITY_MANUAL_MCP')
|
||||
await click('.wb-composer__add-button'); await hoverAddMenuItem('skills')
|
||||
await wait('!!document.querySelector("[data-skill-option]")', 'skill menu from actual registry')
|
||||
await shot('skill-picker'); await click('[data-skill-option]')
|
||||
check('structured skill chip added', await read(`!!document.querySelector('[data-skill-uri="skill://capability-acceptance"]')`))
|
||||
await step('actual-omp-skill-mcp-turn')
|
||||
await click('.cr-send-button:not(.cr-send-button--stop)')
|
||||
await wait('[...document.querySelectorAll("button")].some(e=>e.textContent.includes("允许一次"))', 'MCP runtime approval')
|
||||
await shot('mcp-tool-approval'); await clickText('button', '允许一次', false)
|
||||
const session = await wait('window.ycode.session.list().then(s=>s[0])', 'persisted session')
|
||||
const assistant = await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m[1]?.status!=="streaming"&&m[1])`, 'terminal actual OMP reply')
|
||||
check('actual OMP capability reply', assistant.content, 'CAPABILITY_OK'); check('actual OMP terminal', assistant.status, 'done')
|
||||
check('MCP actual tool result', assistant.tools.some(tool => tool.output.includes('stdio:CAPABILITY_ECHO_INPUT')))
|
||||
check('actual MCP identity retained in persisted tool metadata', assistant.tools.some(tool => tool.metadata?.serverName === 'capability-echo' && tool.metadata?.toolName === 'echo'))
|
||||
await wait(`!!document.querySelector('[data-tool-name="mcp_call_tool"]')`, 'source MCP dedicated renderer')
|
||||
check('selected skill body reached real upstream', requests.some(request => request.selectedSkillBody))
|
||||
await shot('capability-chat-result')
|
||||
await step('automatic-skill-loading')
|
||||
await fill('.wb-composer__input', 'CAPABILITY_AUTO_LOAD'); await click('.cr-send-button:not(.cr-send-button--stop)')
|
||||
const automatic = await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.length===4&&m[3].status!=="streaming"&&m[3])`, 'automatic skill load')
|
||||
check('automatic skill actually loaded by tool', automatic.tools.some(tool => tool.name === 'ycode_load_skill' && tool.output.includes('CAPABILITY_SKILL_BODY_MARKER')))
|
||||
check('automatic skill turn done', automatic.content, 'CAPABILITY_AUTO_OK')
|
||||
await step('complete')
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requests, skill, server, sessionId: session.id, bounds, limitations: ['OS file-picker selection is controlled; actual production UI/IPC/install/runtime run unchanged.', 'Original cloud catalogs, expert packages and whole-state pixel equivalence are not asserted.'] }, null, 2))
|
||||
console.log('CAPABILITY_FLOWS_PASS ' + directory); await cleanup(); app.quit()
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, stage + '-failure.json'), JSON.stringify({ result: 'FAIL', phase, error: String(error), stack: error.stack, checks, requests }, null, 2)).catch(() => {})
|
||||
if (main && !main.isDestroyed()) await shot(stage + '-failure').catch(() => {})
|
||||
console.log('CAPABILITY_FLOWS_FAIL ' + phase + ' ' + String(error)); await cleanup().catch(() => {}); process.exitCode = 1; app.quit()
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ if (!process.versions.electron) {
|
||||
const b = { id: uuid(), title: '另一个会话 Beta', cwd, createdAt: now, updatedAt: now,
|
||||
messages: [{ id: uuid(), role: 'assistant', createdAt: now, status: 'done', content: '另一会话独立内容 Different conversation text.' }] }
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [a, b] }))
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_QA_DIRECTORY: directory,
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_QA_HEADER_FIXTURES: JSON.stringify([a.id, b.id]), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename],
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
// TODO 21 验收:会话输入区的 Git 分支标签 + 上下文用量环。
|
||||
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库。
|
||||
//
|
||||
// 原版依据(固定 REA 归档):
|
||||
// - 运行时几何/结构:ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||
// (WorkBuddy 5.8.3 会话输入区,视口 1336×902):
|
||||
// 左组 `.cr-add-menu`(431,32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561)
|
||||
// 右组 `.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → 语音(1117) → 发送(1157)
|
||||
// 分支标签:radius 999 / padding 5px 8px / gap 8 / max-width 188 / 字体 14/400/22;
|
||||
// 图标按钮 16×16,默认分支字形,悬停换成 copy(opacity 互换);hover 背景 `--wb-bg-secondary`。
|
||||
// 用量环:32×32 / radius 16;内部 16×16 SVG,track circle r=7 stroke-width 2、
|
||||
// progress circle 带 `stroke-dashoffset .3s` 过渡;hover 背景 `--cr-context-usage-hover-bg,#0000000d`。
|
||||
// 浮层:280 宽 / padding 16 / radius 16 / 标题「上下文用量」/ 关闭按钮 / 百分比 20/700/28 /
|
||||
// 「已使用」12/20 / 进度条 8 高 / 五类图例。
|
||||
// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。
|
||||
// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 /
|
||||
// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。
|
||||
// - REA 保留的 `ContextUsageDisplay` 源码进一步确认:百分比固定一位小数,token 达 1K 后
|
||||
// 固定一位小数并显示为 K(例如 `20.0% · 12.8K / 64.0K`)。
|
||||
//
|
||||
// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+
|
||||
// 该回合冻结的真实上下文窗口。历史样本没有分母时保持未知,不套当前模型配置。
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const QA_BRANCH = 'qa-branch'
|
||||
/** 受控上游返回的真实用量(provider prompt_tokens 含缓存,本例无缓存)。 */
|
||||
const QA_PROMPT_TOKENS = 12800
|
||||
const QA_COMPLETION_TOKENS = 40
|
||||
const QA_CONTEXT_WINDOW = 64000
|
||||
const QA_EXPECTED_PERCENT = 20
|
||||
const QA_EXPECTED_PERCENT_TEXT = `${QA_EXPECTED_PERCENT.toFixed(1)}%`
|
||||
const QA_EXPECTED_USED_TEXT = '12.8K'
|
||||
const QA_EXPECTED_TOTAL_TEXT = '64.0K'
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * 7
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/composer-context-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const repo = path.join(directory, 'fixture-repo')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
await fs.mkdir(repo, { recursive: true })
|
||||
// 真实 Git 仓库:分支名固定,供分支标签读取。
|
||||
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' })
|
||||
git('init', '-b', QA_BRANCH)
|
||||
git('config', 'user.email', 'qa@example.com')
|
||||
git('config', 'user.name', 'QA')
|
||||
await fs.writeFile(path.join(repo, 'README.md'), '# qa\n')
|
||||
git('add', '.')
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-m', 'init')
|
||||
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').toString().trim()
|
||||
assert.equal(branch, QA_BRANCH)
|
||||
const now = Date.now()
|
||||
const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [
|
||||
{ id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' },
|
||||
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [],
|
||||
contextUsage: { usage: { inputTokens: 8000, outputTokens: 40, totalTokens: 8040 } } }
|
||||
] }]
|
||||
const nativePath=path.join(data,'omp-sessions','session-native-context-session.jsonl')
|
||||
await fs.mkdir(path.dirname(nativePath),{recursive:true})
|
||||
await fs.writeFile(nativePath,[
|
||||
{id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'old'}]}},
|
||||
{id:'native-reply',parentId:'native-user',type:'message',message:{role:'assistant',model:'old-model',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:now-1000}}
|
||||
].map(value=>JSON.stringify(value)).join('\n'))
|
||||
sessions.push({id:'native-context-session',title:'Native Usage Recovery',cwd:repo,modelId:'local-usage',createdAt:now-1000,updatedAt:now-1000,messages:[
|
||||
{id:'legacy-user',role:'user',content:'old',createdAt:now-1000,status:'done',requestId:'legacy-native',nativeEntryId:'native-user',nativeSessionPath:nativePath},
|
||||
{id:'legacy-reply',role:'assistant',content:'old reply',createdAt:now-900,status:'done',requestId:'legacy-native',modelId:'local-usage'}
|
||||
]})
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||
YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('COMPOSER_CONTEXT_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 300000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(output.slice(-8000))
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, clipboard } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
// GPU 关闭由主进程 bundle 依据 YCODE_DISABLE_GPU 处理;这里不能重复调用(Electron 会抛错)。
|
||||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = [], snapshots = {}, rendererErrors = []
|
||||
let main, server, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source, win = main) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000)
|
||||
})])
|
||||
} finally { clearTimeout(timer) }
|
||||
}
|
||||
async function wait(source, label, win = main) {
|
||||
for (let index = 0; index < 400; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||
function check(label, actual, expected) {
|
||||
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
|
||||
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
|
||||
}
|
||||
async function hover(selector, win = main) {
|
||||
const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [Math.round(box.x+box.width/2),Math.round(box.y+box.height/2)]})()`, win)
|
||||
assert.ok(point, 'hover target: ' + selector)
|
||||
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
|
||||
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1], button: 'none' })
|
||||
await pause(260)
|
||||
}
|
||||
|
||||
const branchSource = `(()=>{const left=document.querySelector('.cr-input-toolbar__left');const pill=left&&left.querySelector('._branchLabel_jdvlk_244');
|
||||
if(!left||!pill)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const style=getComputedStyle(pill);const button=pill.querySelector('._branchLabelIconButton_jdvlk_279');
|
||||
const def=pill.querySelector('._branchLabelIconDefault_jdvlk_267');const copy=pill.querySelector('._branchLabelIconCopy_jdvlk_270');
|
||||
const text=pill.querySelector('._branchLabelText_jdvlk_316');const tstyle=getComputedStyle(text);
|
||||
const addMenu=left.querySelector('.cr-add-menu');const permission=left.querySelector('.cr-permission-setting');
|
||||
const svgDefault=def.querySelector('svg');const svgCopy=copy.querySelector('svg');
|
||||
return {pill:{box:box(pill),radius:style.borderRadius,padding:style.padding,gap:style.gap,maxWidth:style.maxWidth,height:style.height,
|
||||
font:style.fontSize+'/'+style.fontWeight+'/'+style.lineHeight,color:style.color,display:style.display,
|
||||
background:style.backgroundColor,transition:style.transitionProperty+' '+style.transitionDuration},
|
||||
text:{value:text.textContent.trim(),overflow:tstyle.overflow,textOverflow:tstyle.textOverflow,whiteSpace:tstyle.whiteSpace,font:tstyle.fontSize+'/'+tstyle.fontWeight+'/'+tstyle.lineHeight},
|
||||
button:button?{box:box(button),tag:button.tagName}:null,
|
||||
icons:{defaultOpacity:getComputedStyle(def).opacity,copyOpacity:getComputedStyle(copy).opacity,
|
||||
defaultSvg:svgDefault?{box:box(svgDefault),viewBox:svgDefault.getAttribute('viewBox'),className:svgDefault.getAttribute('class'),paths:svgDefault.querySelectorAll('path').length}:null,
|
||||
copySvg:svgCopy?{box:box(svgCopy),viewBox:svgCopy.getAttribute('viewBox'),className:svgCopy.getAttribute('class'),strokeWidth:svgCopy.getAttribute('stroke-width'),rects:svgCopy.querySelectorAll('rect').length,paths:svgCopy.querySelectorAll('path').length}:null},
|
||||
order:{addMenu:addMenu?box(addMenu):null,permission:permission?box(permission):null}}})()`
|
||||
|
||||
const ringSource = `(()=>{const right=document.querySelector('.cr-input-toolbar__right');const ring=right&&right.querySelector('.cr-context-usage');
|
||||
if(!right||!ring)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const style=getComputedStyle(ring);const svg=ring.querySelector('svg.cr-context-usage__ring');
|
||||
const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress');
|
||||
const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector');
|
||||
return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration,
|
||||
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor,ariaLabel:ring.getAttribute('aria-label')},
|
||||
wrapper:wrapper?box(wrapper):null,
|
||||
svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null,
|
||||
track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null,
|
||||
progress:progress?{r:progress.getAttribute('r'),strokeWidth:progress.getAttribute('stroke-width'),strokeLinecap:progress.getAttribute('stroke-linecap'),
|
||||
transform:progress.getAttribute('transform'),dashArray:progress.getAttribute('stroke-dasharray'),dashOffset:Number(progress.getAttribute('stroke-dashoffset')),
|
||||
transition:getComputedStyle(progress).transitionProperty+' '+getComputedStyle(progress).transitionDuration,stroke:getComputedStyle(progress).stroke}:null,
|
||||
firstChild:right.firstElementChild?right.firstElementChild.className:null,
|
||||
modelLeft:model?box(model)[0]:null}})()`
|
||||
|
||||
const popoverSource = `(()=>{const popover=document.querySelector('.cr-context-usage-popover');if(!popover)return null;
|
||||
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(popover);
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
const title=popover.querySelector('.cr-context-usage-popover__title');const close=popover.querySelector('.cr-context-usage-popover__close');
|
||||
const percent=popover.querySelector('.cr-context-usage-popover__percent');const usedValue=popover.querySelector('.cr-context-usage-popover__used-value');
|
||||
const usedLabel=popover.querySelector('.cr-context-usage-popover__used-label');const bar=popover.querySelector('.cr-context-usage-popover__bar');
|
||||
const rows=[...popover.querySelectorAll('.cr-context-usage-popover__row')].map(row=>({name:row.querySelector('.cr-context-usage-popover__row-name')?.textContent,
|
||||
value:row.querySelector('.cr-context-usage-popover__row-value')?.textContent,swatch:box(row.querySelector('.cr-context-usage-popover__swatch')).slice(2)}));
|
||||
return {box:box(popover),width:style.width,padding:style.padding,borderRadius:style.borderRadius,font:style.fontSize+'/'+style.lineHeight,
|
||||
background:style.backgroundColor,zIndex:style.zIndex,
|
||||
title:title?{text:title.textContent,font:getComputedStyle(title).fontSize+'/'+getComputedStyle(title).fontWeight+'/'+getComputedStyle(title).lineHeight}:null,
|
||||
close:close?box(close):null,
|
||||
percent:percent?{text:percent.textContent,font:getComputedStyle(percent).fontSize+'/'+getComputedStyle(percent).fontWeight+'/'+getComputedStyle(percent).lineHeight}:null,
|
||||
usedLabel:usedLabel?{text:usedLabel.textContent,color:getComputedStyle(usedLabel).color}:null,
|
||||
usedValue:usedValue?usedValue.textContent:null,
|
||||
bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null,
|
||||
rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()`
|
||||
|
||||
const requestingTipSource = `(()=>{const tip=document.querySelector('.cr-context-usage-requesting-tip');if(!tip)return null;
|
||||
const style=getComputedStyle(tip);const text=tip.querySelector('.cr-context-usage-requesting-tip__text');
|
||||
return {role:tip.getAttribute('role'),text:text?.textContent,background:style.backgroundColor,border:style.border,
|
||||
borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
|
||||
popoverPresent:!!document.querySelector('.cr-context-usage-popover')}})()`
|
||||
|
||||
;(async () => {
|
||||
// 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。
|
||||
let omitUsage=false
|
||||
server = createServer(async (req, res) => {
|
||||
let raw = ''
|
||||
for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw || '{}')
|
||||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'USAGE_REPLY' } }] })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n`
|
||||
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] }))
|
||||
res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
...(omitUsage?{}:{usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS }}) }))
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
})
|
||||
// 注意:导入主进程 bundle 之前不能 await 任何东西,否则 Electron 会先进入 ready,
|
||||
// bundle 顶层的 app.disableHardwareAcceleration() 就会抛错。这里全部用同步准备。
|
||||
const sync = require('node:fs')
|
||||
server.listen(0, '127.0.0.1')
|
||||
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve))
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||
for (let index = 0; index < 200; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(40)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
main.setSize(1336, 902)
|
||||
main.showInactive()
|
||||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', name: 'Local Usage', providerId: 'custom',
|
||||
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
|
||||
maxInputTokens: QA_CONTEXT_WINDOW, maxOutputTokens: 4096 })})`)
|
||||
await read('window.ycode.models.select("local-usage")')
|
||||
|
||||
await step('conversation-with-git-workspace')
|
||||
await wait(`!!document.querySelector('.cr-input-toolbar__left')`, 'composer toolbar')
|
||||
await wait(`!!document.querySelector('._branchLabel_jdvlk_244')`, 'branch label')
|
||||
const branchInfo = await read(branchSource)
|
||||
snapshots.branch = branchInfo
|
||||
check('branch label shows the real repository branch', branchInfo.text.value, QA_BRANCH)
|
||||
// 原版证据里是 6 字符的 master(82 宽);宽度由分支名长度决定,这里断言固定契约 + 上限。
|
||||
check('branch pill keeps the recovered geometry', [Math.round(branchInfo.pill.box[3]), branchInfo.pill.padding, branchInfo.pill.radius, branchInfo.pill.gap, branchInfo.pill.maxWidth],
|
||||
[32, '5px 8px', '999px', '8px', '188px'])
|
||||
check('branch pill width stays within the recovered cap', branchInfo.pill.box[2] <= 188 && branchInfo.pill.box[2] > 32, true)
|
||||
check('branch text keeps 14/400/22 with ellipsis', [branchInfo.text.font, branchInfo.text.overflow, branchInfo.text.textOverflow, branchInfo.text.whiteSpace],
|
||||
['14px/400/22px', 'hidden', 'ellipsis', 'nowrap'])
|
||||
check('branch icon button is a 16x16 button', [branchInfo.button.tag, branchInfo.button.box[2], branchInfo.button.box[3]], ['BUTTON', 16, 16])
|
||||
check('default glyph is the recovered 16x16 branch icon', [branchInfo.icons.defaultSvg.viewBox, branchInfo.icons.defaultSvg.className, branchInfo.icons.defaultSvg.paths, branchInfo.icons.defaultSvg.box[2]],
|
||||
['0 0 16 16', 'wb-icon', 1, 16])
|
||||
check('hover glyph is the recovered lucide copy icon', [branchInfo.icons.copySvg.viewBox, branchInfo.icons.copySvg.className, branchInfo.icons.copySvg.strokeWidth, branchInfo.icons.copySvg.rects, branchInfo.icons.copySvg.paths],
|
||||
['0 0 24 24', 'lucide lucide-copy wb-icon', '1.5', 1, 1])
|
||||
check('default glyph visible and copy glyph hidden before hover', [branchInfo.icons.defaultOpacity, branchInfo.icons.copyOpacity], ['1', '0'])
|
||||
check('branch label sits 8px after the add menu', Math.round(branchInfo.pill.box[0] - (branchInfo.order.addMenu[0] + branchInfo.order.addMenu[2])), 8)
|
||||
check('permission setting sits 8px after the branch label', Math.round(branchInfo.order.permission[0] - (branchInfo.pill.box[0] + branchInfo.pill.box[2])), 8)
|
||||
|
||||
await step('branch-hover-and-copy')
|
||||
await hover('._branchLabel_jdvlk_244')
|
||||
snapshots.branchHover = await read(`(()=>{const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';pill.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
return {background:getComputedStyle(pill).backgroundColor,hover:pill.matches(':hover'),
|
||||
defaultOpacity:getComputedStyle(pill.querySelector('._branchLabelIconDefault_jdvlk_267')).opacity,
|
||||
copyOpacity:getComputedStyle(pill.querySelector('._branchLabelIconCopy_jdvlk_270')).opacity,
|
||||
secondary:getComputedStyle(pill).getPropertyValue('--wb-bg-secondary').trim(),
|
||||
secondaryResolved:probe('backgroundColor','--wb-bg-secondary')}})()`)
|
||||
check('hovering swaps the glyph to copy', [snapshots.branchHover.defaultOpacity, snapshots.branchHover.copyOpacity], ['0', '1'])
|
||||
check('hovering paints the pill with the resolved --wb-bg-secondary', snapshots.branchHover.background, snapshots.branchHover.secondaryResolved)
|
||||
check('branch pill hover state is real', snapshots.branchHover.hover, true)
|
||||
clipboard.writeText('')
|
||||
await read(`(()=>{document.querySelector('._branchLabelIconButton_jdvlk_279').click();return true})()`)
|
||||
await pause(160)
|
||||
check('clicking the glyph copies the real branch name', clipboard.readText(), QA_BRANCH)
|
||||
|
||||
await step('context-usage-ring')
|
||||
const ring = await read(ringSource)
|
||||
snapshots.ring = ring
|
||||
check('usage ring keeps the recovered 32x32 / radius 16 box', [Math.round(ring.ring.box[2]), Math.round(ring.ring.box[3]), ring.ring.radius, ring.ring.role], [32, 32, '16px', 'button'])
|
||||
check('usage ring is the first element of the right group', ring.firstChild.includes('cr-context-usage-tooltip-trigger'), true)
|
||||
check('usage ring sits 4px before the model selector', Math.round(ring.modelLeft - (ring.ring.box[0] + ring.ring.box[2])), 4)
|
||||
check('ring svg is the recovered 16x16 ring', [ring.svg.viewBox, ring.svg.className, ring.svg.box[2], ring.svg.box[3]], ['0 0 16 16', 'cr-context-usage__ring', 16, 16])
|
||||
check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none'])
|
||||
check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)'])
|
||||
check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s')
|
||||
check('legacy usage without a frozen denominator keeps the ring empty',
|
||||
Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
|
||||
|
||||
await step('usage-requesting-tip-without-denominator')
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!!document.querySelector(".cr-context-usage-popover")', 'known usage with unknown denominator')
|
||||
const legacyPopover = await read(popoverSource)
|
||||
snapshots.legacyPopover = legacyPopover
|
||||
check('known token count is visible even without its historical window',legacyPopover.usedValue,'8.0K/ 未知')
|
||||
check('legacy unknown denominator is not replaced by the current model configuration',legacyPopover.percent.text,'—')
|
||||
check('legacy classifications remain unknown',legacyPopover.rows.map(row=>row.value),['—','—','—','—','—'])
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!document.querySelector(".cr-context-usage-popover")', 'legacy popover closed')
|
||||
check('clicking the ring dismisses the usage panel', await read('!document.querySelector(".cr-context-usage-popover")'), true)
|
||||
|
||||
await step('real-turn-usage')
|
||||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
if(!input)throw new Error('Missing composer input');
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'用量验收');
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
await read(`(()=>{document.querySelector('.cr-input-toolbar__send').click();return true})()`)
|
||||
await wait(`document.body.textContent.includes('USAGE_REPLY')`, 'controlled upstream reply')
|
||||
await wait(`(()=>{const progress=document.querySelector('.cr-context-usage__ring-progress');return progress&&Number(progress.getAttribute('stroke-dashoffset'))<${RING_CIRCUMFERENCE - 1}})()`, 'ring reflects real usage')
|
||||
await pause(400)
|
||||
const usedRing = await read(ringSource)
|
||||
snapshots.usedRing = usedRing
|
||||
const expectedOffset = RING_CIRCUMFERENCE * (1 - QA_EXPECTED_PERCENT / 100)
|
||||
check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true)
|
||||
check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)')
|
||||
check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)')
|
||||
check('ring accessible label uses the same recovered compact usage sentence', usedRing.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
|
||||
await step('usage-tooltip')
|
||||
await hover('.cr-context-usage')
|
||||
const tooltip = await read(`(()=>{const node=document.querySelector('.cr-context-usage-tooltip');if(!node)return null;
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';node.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
const style=getComputedStyle(node);return {text:node.querySelector('.wb-tooltip__content')?.textContent,role:node.getAttribute('role'),
|
||||
background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
|
||||
arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`)
|
||||
snapshots.tooltip = tooltip
|
||||
check('hover tooltip uses the recovered one-decimal and compact-token formatting', tooltip?.text,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow],
|
||||
[tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true])
|
||||
|
||||
await step('usage-popover-with-real-numbers')
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!!document.querySelector(".cr-context-usage-popover")', 'popover with usage')
|
||||
await pause(200)
|
||||
const usedPopover = await read(popoverSource)
|
||||
snapshots.usedPopover = usedPopover
|
||||
check('popover keeps the recovered one-decimal percentage', usedPopover.percent.text, QA_EXPECTED_PERCENT_TEXT)
|
||||
check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px')
|
||||
check('popover keeps the recovered compact used/total pair', usedPopover.usedValue,
|
||||
`${QA_EXPECTED_USED_TEXT}/ ${QA_EXPECTED_TOTAL_TEXT}`)
|
||||
check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary])
|
||||
check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px'])
|
||||
const prepared = await read(`window.ycode.session.messages('context-info-session').then(messages=>{const m=messages.filter(item=>item.role==='assistant').at(-1);return {breakdown:m?.contextBreakdown,requestId:m?.requestId}})`)
|
||||
snapshots.preparedInput=prepared
|
||||
check('real OMP provider hook records this request, not an unrelated historical sample',
|
||||
[prepared.breakdown?.scope,prepared.breakdown?.requestId,prepared.breakdown?.method],['provider-request',prepared.requestId,'utf8-estimate'])
|
||||
check('classification contains actual system, tool definitions, dialogue and skill catalogue',
|
||||
['system','tools','conversation','skills'].every(key=>prepared.breakdown?.counts[key]>0),true)
|
||||
check('category sum is independent from controlled upstream count',prepared.breakdown?.inputTokens!==QA_PROMPT_TOKENS,true)
|
||||
check('classification bar uses its own independent local estimate',Math.abs(parseFloat(usedPopover.bar.fillWidth)-Math.min(100,prepared.breakdown.inputTokens/QA_CONTEXT_WINDOW*100))<0.01,true)
|
||||
check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name),
|
||||
['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能'])
|
||||
check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]])
|
||||
check('legend values are explicitly labeled local estimates',usedPopover.rows.every(row=>row.value.startsWith('≈')),true)
|
||||
await fs.writeFile(path.join(directory,'context-breakdown-light.png'),(await main.webContents.capturePage()).toPNG())
|
||||
|
||||
await step('frozen-window-survives-model-config-change')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', previousId: 'local-usage', name: 'Local Usage', providerId: 'custom',
|
||||
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
|
||||
maxInputTokens: QA_CONTEXT_WINDOW * 2, maxOutputTokens: 4096 })})`)
|
||||
await pause(250)
|
||||
const frozenRing = await read(ringSource)
|
||||
snapshots.frozenWindow = frozenRing
|
||||
check('historical usage keeps its own frozen denominator after model config changes',
|
||||
frozenRing.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
check('historical usage does not silently inherit the newly configured model window',
|
||||
Math.abs(frozenRing.progress.dashOffset - expectedOffset) < 0.6, true)
|
||||
|
||||
await step('dark-theme-via-native-settings')
|
||||
// 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。
|
||||
const lightTokens = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
return {popoverBackground:getComputedStyle(popover).backgroundColor,bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||
pillColor:getComputedStyle(pill).color,scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||
await read(`window.ycode.app.openSettings('appearance')`)
|
||||
let settings
|
||||
for (let index = 0; index < 200; index++) {
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
if (settings && !settings.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(settings, 'settings window')
|
||||
await pause(600)
|
||||
snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings)
|
||||
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||
await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings)
|
||||
await wait(`!!document.querySelector('.appearance-card')`, 'appearance panel', settings)
|
||||
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="深色"]');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme')
|
||||
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope')
|
||||
await pause(250)
|
||||
const darkPopover = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
return {popoverBackground:getComputedStyle(popover).backgroundColor,pillColor:getComputedStyle(pill).color,
|
||||
bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||
tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'),
|
||||
tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||
snapshots.dark = { lightTokens, darkPopover }
|
||||
await fs.writeFile(path.join(directory,'context-breakdown-dark.png'),(await main.webContents.capturePage()).toPNG())
|
||||
check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark'])
|
||||
check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)')
|
||||
check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)')
|
||||
check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true])
|
||||
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="浅色"]');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
|
||||
await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme')
|
||||
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope')
|
||||
await read(`document.querySelector('.settings-window-close').click()`, settings)
|
||||
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
|
||||
check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light'])
|
||||
|
||||
await step('persisted-usage-after-renderer-reload')
|
||||
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`, 'real turn persistence barrier')
|
||||
const persistedUsage = await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(message=>message.role==='assistant').at(-1)?.contextUsage)`)
|
||||
snapshots.persistedUsage = persistedUsage
|
||||
check('actual turn stores its upstream input sample and original model window',
|
||||
[persistedUsage?.usage?.inputTokens, persistedUsage?.contextWindow], [QA_PROMPT_TOKENS, QA_CONTEXT_WINDOW])
|
||||
const reloaded = new Promise(resolve => main.webContents.once('did-finish-load', resolve))
|
||||
main.webContents.reload()
|
||||
await reloaded
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')===${JSON.stringify(`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)}`,
|
||||
'restored usage with frozen denominator')
|
||||
snapshots.reloadedUsage = await read(ringSource)
|
||||
check('renderer reload recovers usage without adopting the newly doubled model window',
|
||||
snapshots.reloadedUsage.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
await fs.writeFile(path.join(directory, 'context-usage-restored.png'), (await main.webContents.capturePage()).toPNG())
|
||||
await step('new-request-without-provider-usage')
|
||||
omitUsage=true
|
||||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'NO_USAGE_NEW_REQUEST');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
await read(`document.querySelector('.cr-input-toolbar__send').click()`)
|
||||
await wait(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='user').at(-1)?.content==='NO_USAGE_NEW_REQUEST')`,'new round created')
|
||||
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`,'no-usage round complete')
|
||||
const noUsage=await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='assistant').at(-1))`)
|
||||
snapshots.noUsage={contextUsage:noUsage.contextUsage,contextBreakdown:noUsage.contextBreakdown,requestId:noUsage.requestId}
|
||||
check('a new no-usage request cannot inherit the previous provider total',noUsage.contextUsage??null,null)
|
||||
check('the new request still retains its independently prepared classification',noUsage.contextBreakdown?.requestId,noUsage.requestId)
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'local estimate clearly identified')
|
||||
const noUsageReload=new Promise(resolve=>main.webContents.once('did-finish-load',resolve));main.webContents.reload();await noUsageReload
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'estimate restored without reviving old provider sample')
|
||||
check('reopening a no-usage round does not pick an older assistant sample',
|
||||
await read(`window.ycode.session.contextUsage('context-info-session').then(info=>[!!info.sample,info.breakdown?.requestId])`),[false,noUsage.requestId])
|
||||
|
||||
await step('native-history-usage-recovery')
|
||||
const nativeUpdatedBefore=await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`)
|
||||
await wait(`!!document.querySelector('[data-session-id="native-context-session"]')`,'legacy sidebar item')
|
||||
await read(`document.querySelector('[data-session-id="native-context-session"]').click()`)
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.includes('5.2K / 未知')`,'native sample recovered without a fabricated denominator')
|
||||
await read(`document.querySelector('.cr-context-usage').click()`)
|
||||
await wait(`document.querySelector('.yc-context-source')?.textContent.includes('原生记录恢复')`,'source of recovered sample visible')
|
||||
const recovered=await read(`window.ycode.session.messages('native-context-session').then(messages=>messages.at(-1).contextUsage)`)
|
||||
snapshots.nativeRecovered=recovered
|
||||
check('real IPC saves the recovered native sample with its provenance',
|
||||
[recovered.usage.inputTokens,recovered.contextWindow??null,recovered.source,recovered.nativeEntryId],[5161,null,'native-history','native-reply'])
|
||||
check('restoring usage metadata does not reorder the sidebar task',await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`),nativeUpdatedBefore)
|
||||
await fs.writeFile(path.join(directory,'context-native-recovered.png'),(await main.webContents.capturePage()).toPNG())
|
||||
check('querying old records does not start a native agent',await fs.readdir(path.join(process.env.YCODE_USER_DATA_DIR,'context-usage')).then(files=>files.some(name=>name.includes('native-context-session'))),false)
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
server.close()
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP kernel + controlled local OpenAI-compatible upstream + real git repository; measured branch pill, usage ring, tooltip and popover in the live window after a real turn.',
|
||||
sourceEvidence: ['ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a', 'contexts-BkjwYSZT.css', 'src-CyXHA0IC.css', 'chatInput.contextUsage.*'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('COMPOSER_CONTEXT_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('COMPOSER_CONTEXT_FAILURE ' + JSON.stringify(failure))
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
if (server) server.close()
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||||
console.error(error); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -79,8 +79,7 @@ if (!process.versions.electron) {
|
||||
]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
// 原生滚动条只在真实合成时绘制;本脚本不设 YCODE_QA_OFFSCREEN,以便像素级比对。
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/custom-model-settings-'))
|
||||
for (const name of ['data', 'home']) await fs.mkdir(path.join(directory, name))
|
||||
await fs.cp(path.resolve('out'), path.join(directory, 'build/out'), { recursive: true })
|
||||
await fs.cp(path.resolve('resources/model-metadata'), path.join(directory, 'build/resources/model-metadata'), { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home') }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', data => { output += data; if (data.toString().includes('CUSTOM_MODEL_')) process.stdout.write(data) }); child.stderr.on('data', data => output += data)
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
child.on('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output); console.log(JSON.stringify({ directory, exitCode: code })); if (code) console.error(output.slice(-5000)); process.exitCode = code ? 1 : 0 })
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron'), { pathToFileURL } = require('node:url'), { createServer } = require('node:http')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
app.setPath('home', path.join(directory, 'home')); app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
|
||||
let main, settings, phase = 'boot'
|
||||
const checks = [], servers = [], requests = [], rendererErrors = []
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = (source, win = settings) => win.webContents.executeJavaScript(source)
|
||||
const check = (label, actual, expected = true) => checks.push({ label, actual, expected, pass: JSON.stringify(actual) === JSON.stringify(expected) })
|
||||
async function wait(source, label, win = settings) { for (let i = 0; i < 500; i++) { const value = await read(source, win); if (value) return value; await pause(20) }; throw Error('Timeout: ' + label) }
|
||||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||
const click = selector => read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing control: '+${JSON.stringify(selector)});node.click();return true})()`)
|
||||
async function fill(selector, value) { await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing input: '+${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(node,${JSON.stringify(value)});node.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) }
|
||||
async function option(value) {
|
||||
const source = `(()=>{const nodes=[...document.querySelectorAll('[role="option"]')];const node=nodes.find(n=>n.textContent.trim()===${JSON.stringify(value)}&&n.getClientRects().length);if(!node||getComputedStyle(node.closest('.wb-select__panel')).pointerEvents==='none'||getComputedStyle(node.closest('.wb-popover')).visibility==='hidden'||Number(getComputedStyle(node.closest('.wb-popover')).opacity)<.99)return false;node.click();return true})()`
|
||||
await wait(source, 'select option ' + value)
|
||||
await wait(`![...document.querySelectorAll('[role="option"]')].some(node=>node.textContent.trim()===${JSON.stringify(value)}&&node.getClientRects().length)`, 'option committed ' + value)
|
||||
}
|
||||
async function shot(name) {
|
||||
await read(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(true))))`)
|
||||
await read(`(async()=>{const menu=document.querySelector('.custom-model-options');if(menu)await Promise.allSettled(menu.getAnimations({subtree:true}).filter(animation=>animation.effect.getComputedTiming().iterations!==Infinity).map(animation=>animation.finished));await new Promise(resolve=>requestAnimationFrame(()=>resolve(true)))})()`)
|
||||
await fs.writeFile(path.join(directory, name + '.png'), (await settings.webContents.capturePage()).toPNG())
|
||||
await fs.writeFile(path.join(directory, name + '.layout.json'), JSON.stringify(await read(`(()=>{const body=document.querySelector('.models-settings-panel__editor-body'),menu=document.querySelector('.custom-model-options');return {theme:document.documentElement.dataset.theme,body:body?{top:body.scrollTop,height:body.clientHeight,scrollHeight:body.scrollHeight}:null,menu:menu?{x:menu.getBoundingClientRect().x,y:menu.getBoundingClientRect().y,width:menu.getBoundingClientRect().width,opacity:getComputedStyle(menu).opacity}:null}})()`), null, 2))
|
||||
}
|
||||
function anthropicStream(model) { return [
|
||||
{ type: 'message_start', message: { id: 'msg_ui', type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 20, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'MODEL_PROTOCOL_OK' } },
|
||||
{ type: 'content_block_stop', index: 0 }, { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 4 } }, { type: 'message_stop' }
|
||||
].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join('') }
|
||||
async function upstream(protocol, model, options = {}) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://local')
|
||||
const validAuth = options.noKey ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
|
||||
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-model-key' && !req.headers.authorization : protocol === 'gemini' ? req.headers['x-goog-api-key'] === 'controlled-model-key' : req.headers.authorization === 'Bearer controlled-model-key'
|
||||
const listPath = protocol === 'gemini' ? '/v1beta/models' : '/v1/models'
|
||||
const generationPath = protocol === 'anthropic' ? '/v1/messages' : protocol === 'gemini' ? `/v1beta/models/${model}:streamGenerateContent` : protocol === 'responses' ? '/v1/responses' : '/v1/chat/completions'
|
||||
if (req.method === 'GET') {
|
||||
if (url.pathname !== listPath || !validAuth || options.noList) { res.writeHead(404); res.end('{}'); return }
|
||||
options.listStarted = true
|
||||
if (options.listDelay) await pause(options.listDelay)
|
||||
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(protocol === 'gemini' ? { models: options.emptyList ? [] : [{ name: 'models/' + model, displayName: model, supportedGenerationMethods: ['generateContent'] }] } : { data: options.emptyList ? [] : [{ id: model }, ...(options.additionalModels ?? []).map(id => ({ id }))] })); options.listEnded = true; return
|
||||
}
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
const body = JSON.parse(bytes || '{}'), nonstreamGemini = protocol === 'gemini' && url.pathname === `/v1beta/models/${model}:generateContent`
|
||||
requests.push({ protocol, path: req.url, stream: !!body.stream || url.searchParams.get('alt') === 'sse', body, authorized: validAuth,
|
||||
authShape: { names: Object.keys(req.headers), bearerMatches: req.headers.authorization === 'Bearer controlled-model-key', xKeyMatches: req.headers['x-api-key'] === 'controlled-model-key', xKeyLength: String(req.headers['x-api-key'] ?? '').length } })
|
||||
if (!validAuth) { res.writeHead(401); res.end('{}'); return }
|
||||
if (url.pathname !== generationPath && !nonstreamGemini) { res.writeHead(404); res.end('{}'); return }
|
||||
const streaming = body.stream || url.searchParams.get('alt') === 'sse'
|
||||
if (!streaming) {
|
||||
options.probeStarted = true
|
||||
if (options.probeDelay) await pause(options.probeDelay)
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(protocol === 'anthropic' ? { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'OK' }] }
|
||||
: protocol === 'gemini' ? { candidates: [{ content: { role: 'model', parts: [{ text: 'OK' }] } }] }
|
||||
: protocol === 'responses' ? { id: 'resp_test', object: 'response', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'OK' }] }] }
|
||||
: { choices: [{ message: { role: 'assistant', content: 'OK' } }] })); options.probeEnded = true; return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (protocol === 'anthropic') res.end(anthropicStream(model))
|
||||
else if (protocol === 'gemini') res.end('data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'MODEL_PROTOCOL_OK' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 4, totalTokenCount: 24 } }) + '\n\n')
|
||||
else if (protocol === 'responses') {
|
||||
const message = { id: 'msg_resp', type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'MODEL_PROTOCOL_OK' }] }
|
||||
res.end([{ type: 'response.output_item.added', output_index: 0, item: { ...message, content: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: message.id, delta: 'MODEL_PROTOCOL_OK' }, { type: 'response.completed', response: { id: 'resp_actual', status: 'completed', output: [message], usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } } }].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join(''))
|
||||
} else res.end([{ choices: [{ index: 0, delta: { role: 'assistant', content: 'MODEL_PROTOCOL_OK' }, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 } }].map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server)
|
||||
return `127.0.0.1:${server.address().port}`
|
||||
}
|
||||
;(async () => {
|
||||
await import(pathToFileURL(path.join(directory, 'build/out/main/index.mjs')).href); await app.whenReady()
|
||||
for (let i = 0; i < 300; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(20) }
|
||||
await wait(`!!window.ycode&&!!document.querySelector('.chat-pane')`, 'main app', main)
|
||||
await wait(`!!document.querySelector('.cr-model-selector__trigger')`, 'composer model selector', main)
|
||||
await read(`document.querySelector('.cr-model-selector__trigger').click()`, main)
|
||||
await wait(`document.querySelector('.cr-model-selector__menu > .cr-menu-item')?.textContent.trim()==='配置自定义模型'`, 'configure custom models action', main)
|
||||
await read(`document.querySelector('.cr-model-selector__menu > .cr-menu-item').click()`, main)
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
settings.webContents.on('console-message', event => { if (event.level === 'error') rendererErrors.push(event.message) })
|
||||
await wait(`!!document.querySelector('.models-settings-panel')`, 'model list page')
|
||||
check('configure custom models opens the list without an add dialog', await read(`!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
check('model page has no metadata update button', await read(`![...document.querySelectorAll('.models-settings-panel button')].some(node=>node.textContent.includes('更新元数据'))`))
|
||||
await click('.models-settings-panel__add-button')
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'model editor')
|
||||
check('keyless switch and explanatory text are removed', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('无需')`))
|
||||
await step('custom-provider-only')
|
||||
await click('.models-settings-panel__provider-select'); await option('自定义')
|
||||
check('only custom provider appears in the editor', await read(`document.querySelector('.models-settings-panel__provider-select').textContent.trim()`), '自定义')
|
||||
const source = await read(`window.ycode.models.metadataStatus()`)
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(directory, 'build/resources/model-metadata/manifest.json'), 'utf8'))
|
||||
check('bundled catalog matches the fetched snapshot', [source.sha256, source.providers, source.models], [manifest.sha256, manifest.providers, manifest.models])
|
||||
await shot('custom-editor-empty-light')
|
||||
const cases = [{ protocol: 'openai', model: 'gpt-5.4' }, { protocol: 'anthropic', model: 'claude-sonnet-4-6' }, { protocol: 'gemini', model: 'gemini-2.5-flash' }, { protocol: 'responses', model: 'gpt-5.4-pro' }, { protocol: 'responses', model: 'gpt-6.1-sol', extended: true }]
|
||||
const profiles = []
|
||||
for (let index = 0; index < cases.length; index++) {
|
||||
const item = cases[index]; await step('form-' + item.protocol)
|
||||
if (index) { await read(`window.ycode.app.openSettings('model','new')`, main); await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'next model editor') }
|
||||
const domain = await upstream(item.protocol, item.model, index === 0 ? { additionalModels: [item.model + '-alt'] } : {})
|
||||
await fill('input[placeholder*="api.example.com"]', domain); await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await option(item.model)
|
||||
await wait(`document.querySelector('.custom-model-id-combobox input').value===${JSON.stringify(item.model)}&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'metadata applied')
|
||||
const metadata = await read(`window.ycode.models.metadata(${JSON.stringify(item.model)},'auto')`)
|
||||
check(item.protocol + ' metadata found', !!metadata)
|
||||
check(item.protocol + ' metadata descriptions and total-context field are removed', await read(`!document.querySelector('.custom-model-metadata')&&!document.querySelector('[aria-label="总上下文窗口"]')&&!document.querySelector('.models-settings-panel__editor-body').textContent.includes('参考价格')`))
|
||||
if (index === 0) {
|
||||
await fill('.custom-model-id-combobox input', '5.4-alt')
|
||||
await wait(`document.querySelectorAll('.custom-model-options [role="option"]').length===1`, 'inline model search')
|
||||
check('one combobox searches the list directly without a second search field', await read(`document.querySelector('.custom-model-id-combobox input').getAttribute('role')==='combobox'&&!document.querySelector('.custom-model-options input')`))
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}))`)
|
||||
await wait(`!!document.querySelector('.custom-model-id-combobox input').getAttribute('aria-activedescendant')`, 'active keyboard option')
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||||
await wait(`document.querySelector('.custom-model-id-combobox input').value==='gpt-5.4-alt'`, 'keyboard selection')
|
||||
await fill('.custom-model-id-combobox input', 'vendor/manual-model')
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||||
check('an unmatched manual model stays editable and is not replaced', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'vendor/manual-model')
|
||||
await fill('.custom-model-id-combobox input', item.model)
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
|
||||
check('Escape closes only the model list', await read(`!document.querySelector('.custom-model-options')&&!!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'original model metadata restored')
|
||||
}
|
||||
await shot('custom-editor-' + item.protocol + '-light')
|
||||
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
|
||||
if (item.extended) {
|
||||
await read(`(()=>{const fields=document.querySelectorAll('.models-settings-panel__number-grid .models-settings-panel__field');fields[0].querySelectorAll('button').forEach(button=>{if(button.textContent==='500K')button.click()});fields[1].querySelectorAll('button').forEach(button=>{if(button.textContent==='128K')button.click()})})()`)
|
||||
check('500K and 128K shortcuts use exact catalog-compatible token counts', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000', '128000'])
|
||||
check('both large presets show their selected state', await read(`[...document.querySelectorAll('.models-settings-panel__quick-value.wb-button--secondary')].map(node=>node.textContent)`), ['500K', '128K'])
|
||||
check('fields configure total context and output', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.getAttribute('aria-label'))`), ['上下文','输出'])
|
||||
check('live budgets show 372K input and compression at 500K total', await read(`document.querySelector('[data-context-budget]').textContent.includes('372,000 tokens · 自动压缩阈值:372,000')`))
|
||||
}
|
||||
await shot('custom-editor-' + item.protocol + '-metadata-light')
|
||||
if (item.extended) await shot('custom-editor-500k-128k-light')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'save with protocol verification')
|
||||
const registry = await read(`window.ycode.models.list()`), profile = registry.models.find(model => model.id === item.model)
|
||||
assert(profile, 'saved profile ' + item.model); profiles.push(profile)
|
||||
check(item.protocol + ' stores only custom provider', profile.providerId, 'custom')
|
||||
check(item.protocol + ' automatically negotiated generation API', profile.endpoint.api, item.protocol === 'anthropic' ? 'anthropic-messages' : item.protocol === 'gemini' ? 'google-generative-ai' : item.protocol === 'responses' ? 'openai-responses' : 'openai-completions')
|
||||
check(item.protocol + ' saves actual capabilities', [profile.supportsImages, profile.supportsReasoning, profile.supportsToolCall], [metadata.supportsImages, metadata.supportsReasoning, metadata.supportsToolCall])
|
||||
check(item.protocol + ' saves metadata thinking defaults', [profile.onlyReasoning, profile.canDisableThinking, profile.defaultEffort, profile.supportedEfforts], [metadata.onlyReasoning, metadata.canDisableThinking, metadata.defaultEffort, metadata.supportedEfforts])
|
||||
check(item.protocol + ' saves total context and derived input budget', [profile.maxInputTokens, profile.maxOutputTokens, profile.contextWindowTokens], item.extended ? [372000, 128000, 500000] : [metadata.maxInputTokens, metadata.maxOutputTokens, metadata.contextWindowTokens])
|
||||
check(item.protocol + ' saves the matching tier reference', profile.price, item.extended ? metadata.priceTiers.find(tier=>tier.minContext===272000) : metadata.price)
|
||||
check(item.protocol + ' retains no plaintext key in renderer view', !JSON.stringify(profile).includes('controlled-model-key'))
|
||||
await step('real-chat-' + item.protocol)
|
||||
const session = await read(`window.ycode.session.create('Protocol ${item.protocol}',{modelId:${JSON.stringify(item.model)}})`, main)
|
||||
const before = requests.length
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Reply briefly with MODEL_PROTOCOL_OK.'})`, main)
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'real native turn completes', main)
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main), assistant = messages.filter(row => row.role === 'assistant').at(-1)
|
||||
check(item.protocol + ' production chat completes through actual OMP', [assistant.status, assistant.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
|
||||
check(item.protocol + ' actual chat reaches configured native server', requests.slice(before).some(request => request.protocol === item.protocol && request.stream && request.authorized))
|
||||
const actualRequest = requests.slice(before).find(request => request.protocol === item.protocol && request.stream)?.body
|
||||
if (item.extended) {
|
||||
check('large output budget reaches the actual Responses provider request', actualRequest.max_output_tokens, 128000)
|
||||
check('context accounting uses the configured 500K total', assistant.contextBreakdown?.contextWindow, 500000)
|
||||
const managedRoot = path.join(directory, 'home', '.omp', 'profiles')
|
||||
const yamls = await Promise.all((await fs.readdir(managedRoot)).map(async name => ({name,text:await fs.readFile(path.join(managedRoot, name, 'agent', 'models.yml'), 'utf8').catch(()=>'')})))
|
||||
const managed = yamls.find(item=>item.text.includes('gpt-6.1-sol'))
|
||||
check('real OMP profile uses 500K total and 128K output', !!managed && /contextWindow: 500000/.test(managed.text) && /maxTokens: 128000/.test(managed.text))
|
||||
const budgetConfig = await fs.readFile(path.join(managedRoot, managed.name, 'agent', 'config.yml'), 'utf8')
|
||||
check('OMP runtime config reserves output and sets the dynamic compression threshold', /reserveTokens: 128000/.test(budgetConfig) && /thresholdTokens: 372000/.test(budgetConfig))
|
||||
const executable = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA, 'Ygent', 'omp.exe')
|
||||
const parsed = JSON.parse(require('node:child_process').execFileSync(executable, ['config','get','compaction.thresholdTokens','--json'], {encoding:'utf8',windowsHide:true,timeout:15000,env:{...process.env,OMP_PROFILE:managed.name,USERPROFILE:path.join(directory,'home'),HOME:path.join(directory,'home')}}))
|
||||
check('installed OMP loads the generated compression threshold', parsed.value ?? parsed, 372000)
|
||||
}
|
||||
check(item.protocol + ' actual request uses the automatically selected thinking strength', item.protocol === 'gemini'
|
||||
? actualRequest.generationConfig?.thinkingConfig?.thinkingBudget === 1024
|
||||
: (item.protocol === 'anthropic' ? actualRequest.output_config?.effort : item.protocol === 'responses' ? actualRequest.reasoning?.effort : actualRequest.reasoning_effort) === metadata.defaultEffort)
|
||||
check(item.protocol + ' native request retains context breakdown observations', !!assistant.contextBreakdown && assistant.contextBreakdown.scope === 'provider-request' && assistant.contextBreakdown.counts.system > 0)
|
||||
}
|
||||
async function edit(profile) {
|
||||
await read(`(()=>{const row=[...document.querySelectorAll('.models-settings-panel__row')].find(row=>row.querySelector('.models-settings-panel__row-name').textContent===${JSON.stringify(profile.name)});row.querySelector('button').click()})()`)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'edit saved model')
|
||||
}
|
||||
const largeProfile = profiles.find(profile=>profile.id==='gpt-6.1-sol')
|
||||
await edit(largeProfile)
|
||||
check('reopening keeps the 500K and 128K budgets', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000','128000'])
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '64000')
|
||||
check('changing output dynamically recalculates input and keeps 15 percent compression headroom', await read(`document.querySelector('[data-context-budget]').textContent.includes('436,000 tokens · 自动压缩阈值:425,000')`))
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '131072')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`document.querySelector('.model-ui-error')?.textContent.includes('128,000')`, 'catalog output ceiling validation')
|
||||
check('invalid output budget keeps the editor open', await read(`!!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
check('invalid output budget preserves the saved model', (await read(`window.ycode.models.list()`)).models.find(model=>model.id===largeProfile.id).maxOutputTokens, 128000)
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
await step('existing-secret-list-does-not-downgrade-responses')
|
||||
const responsesProfile = profiles.find(profile => profile.id === 'gpt-5.4-pro')
|
||||
await edit(responsesProfile)
|
||||
check('editing never reveals saved key', await read(`document.querySelector('input[type="password"]').value`), '')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await option(responsesProfile.model)
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'unchanged model saved')
|
||||
check('fetching with saved key preserves verified Responses routing', (await read(`window.ycode.models.list()`)).models.find(model => model.id === responsesProfile.id).endpoint.api, 'openai-responses')
|
||||
|
||||
await step('manual-overrides-survive-reopen')
|
||||
const profile = profiles.find(profile => profile.id === 'gpt-5.4')
|
||||
await edit(profile)
|
||||
await fill('.models-settings-panel__number-grid input', '32768')
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '4096')
|
||||
await read(`[...document.querySelectorAll('.models-settings-panel__checkbox-item')].find(node=>node.textContent.trim()==='图片输入').querySelector('input').click()`)
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual overrides saved')
|
||||
const overridden = (await read(`window.ycode.models.list()`)).models.find(model => model.id === profile.id)
|
||||
check('manual total/output and image override are persisted with derived input', [overridden.maxInputTokens, overridden.maxOutputTokens, overridden.contextWindowTokens, overridden.supportsImages], [28672, 4096, 32768, false])
|
||||
await edit(overridden)
|
||||
check('reopening retains manual input/output limits', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['32768', '4096'])
|
||||
await fill('.custom-model-id-combobox input', 'manually-entered-model')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'unknown metadata settled')
|
||||
check('model input remains freely editable', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'manually-entered-model')
|
||||
check('unknown model keeps manual capability controls without metadata copy', await read(`!!document.querySelector('.models-settings-panel__advanced')&&!document.querySelector('.custom-model-metadata')`))
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
|
||||
await step('manual-model-without-models-api-or-key')
|
||||
const manualDomain = await upstream('openai', 'manual-unlisted', { noList: true, noKey: true })
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'edit saved model')
|
||||
await fill('input[placeholder*="api.example.com"]', manualDomain)
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('.model-ui-error')`, 'missing models API feedback')
|
||||
await fill('.custom-model-id-combobox input', 'manual-unlisted')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'manual model ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual keyless save')
|
||||
const manual = (await read(`window.ycode.models.list()`)).models.find(model => model.id === 'manual-unlisted')
|
||||
check('manual model without models API saves a verified keyless route', [manual.authMode, manual.hasApiKey, manual.endpoint.api, manual.endpoint.verifiedBy], ['none', false, 'openai-completions', 'generation'])
|
||||
|
||||
await step('keyless-gemini-production-fallback')
|
||||
const keylessId = 'gemini-keyless-controlled'
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini form')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', keylessId, { noKey: true, noList: true }))
|
||||
await fill('.custom-model-id-combobox input', keylessId)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'keyless Gemini ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini saved')
|
||||
const keylessSession = await read(`window.ycode.session.create('Keyless Gemini',{modelId:${JSON.stringify(keylessId)}})`, main)
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(keylessSession.id)},text:'Reply briefly.'})`, main)
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'keyless Gemini turn', main)
|
||||
const keylessReply = (await read(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)})`, main)).filter(row => row.role === 'assistant').at(-1)
|
||||
check('keyless Gemini completes through production native HTTP gateway', [keylessReply.status, keylessReply.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
|
||||
|
||||
await step('clear-saved-key-without-an-auth-switch')
|
||||
const clearable = {}, clearId = 'clearable-controlled'
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model form')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('openai', clearId, clearable))
|
||||
await fill('input[type="password"]', 'controlled-model-key')
|
||||
await fill('.custom-model-id-combobox input', clearId)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clearable model ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model saved')
|
||||
const clearProfile = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId)
|
||||
await edit(clearProfile)
|
||||
clearable.noKey = true
|
||||
await read(`document.querySelector('input[type="password"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`)
|
||||
await fill('.custom-model-id-combobox input', clearId + '-renamed')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clear and rename ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'cleared key persisted')
|
||||
const cleared = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId + '-renamed')
|
||||
check('clearing the saved key remains keyless after changing the model ID', [cleared.authMode, cleared.hasApiKey], ['none', false])
|
||||
|
||||
await step('stale-model-fetch-and-connection-test')
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'race test form')
|
||||
const slowList = { listDelay: 300 }, slowDomain = await upstream('openai', 'old-list', slowList)
|
||||
await fill('input[placeholder*="api.example.com"]', slowDomain); await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
while (!slowList.listStarted) await pause(10)
|
||||
const slowProbe = { probeDelay: 300 }, probeDomain = await upstream('openai', 'old-probe', slowProbe)
|
||||
await fill('input[placeholder*="api.example.com"]', probeDomain)
|
||||
while (!slowList.listEnded) await pause(10)
|
||||
check('a late old-domain model list cannot refill the new-domain picker', await read(`document.querySelector('.custom-model-id-toggle').disabled`))
|
||||
await fill('.custom-model-id-combobox input', 'old-probe')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'initial probe model ready')
|
||||
await click('.models-settings-panel__editor-test-connection')
|
||||
while (!slowProbe.probeStarted) await pause(10)
|
||||
await fill('.custom-model-id-combobox input', 'new-probe')
|
||||
while (!slowProbe.probeEnded) await pause(10)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'late probe settled')
|
||||
const beforeProbeSave = requests.length
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'new model verified and saved')
|
||||
check('a late test result cannot mark a changed model verified', requests.slice(beforeProbeSave).some(request => request.body.model === 'new-probe' && !request.stream))
|
||||
|
||||
await step('light-dark-form-and-menu')
|
||||
await read(`window.ycode.app.openSettings('appearance')`, main)
|
||||
await wait(`!!document.querySelector('.appearance-card--dark')`, 'appearance page')
|
||||
await click('.appearance-card--dark')
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme persisted')
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'dark model form')
|
||||
check('supplier label no longer claims OpenAI only', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('仅支持 OpenAI')`))
|
||||
await shot('custom-editor-empty-dark')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('openai', 'gpt-5.4', { emptyList: true }))
|
||||
await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('.model-ui-toast')?.textContent.includes('空列表')`, 'empty model list feedback')
|
||||
check('empty lists allow manual input', await read(`!document.querySelector('.custom-model-id-combobox input').disabled`))
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', 'gemini-2.5-flash'))
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('[role="option"]')`, 'dark model dropdown')
|
||||
await shot('custom-editor-model-list-dark')
|
||||
check('the dropdown aligns with the full input width', await read(`(()=>{const input=document.querySelector('.custom-model-id-combobox').getBoundingClientRect(),list=document.querySelector('.custom-model-options').getBoundingClientRect();return Math.abs(input.x-list.x)<1&&Math.abs(input.width-list.width)<1})()`))
|
||||
check('the settled model dropdown is visible and opaque', await read(`(()=>{const style=getComputedStyle(document.querySelector('.custom-model-options'));return style.visibility==='visible'&&Number(style.opacity)>.99})()`))
|
||||
await option('gemini-2.5-flash')
|
||||
await fill('.custom-model-id-combobox input', 'gemini-2.5-flash')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'dark metadata ready')
|
||||
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
|
||||
await shot('custom-editor-metadata-dark')
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
await step('metadata-update-in-about')
|
||||
await read(`window.ycode.app.openSettings('helpFeedback')`, main)
|
||||
await wait(`!!document.querySelector('#settings-about-model-metadata button')`, 'metadata update in About')
|
||||
check('About contains an enabled metadata update action', await read(`!document.querySelector('#settings-about-model-metadata button').disabled`))
|
||||
await shot('about-model-metadata-dark')
|
||||
const originalFetch = globalThis.fetch
|
||||
let refreshRequests = 0
|
||||
globalThis.fetch = async (...args) => {
|
||||
if (String(args[0]) !== 'https://models.dev/api.json') return originalFetch(...args)
|
||||
refreshRequests++
|
||||
return new Response(await fs.readFile(path.join(directory, 'build/resources/model-metadata/models.dev.json')), { headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
try {
|
||||
await click('#settings-about-model-metadata button')
|
||||
await wait(`document.querySelector('.help-feedback-panel__metadata-feedback')?.textContent==='模型元数据已更新'`, 'metadata refreshed through About')
|
||||
check('About update calls the real metadata service once', refreshRequests, 1)
|
||||
} finally { globalThis.fetch = originalFetch }
|
||||
check('no renderer errors', rendererErrors, [])
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated built UI/preload/IPC, bundled metadata, encrypted storage and actual native OMP against loopback HTTP; no external model calls.', source, profiles, requests, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('CUSTOM_MODEL_UI_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('CUSTOM_MODEL_UI_FAILURE ' + JSON.stringify(failure))
|
||||
for (const server of servers) server.closeAllConnections()
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, requests, rendererErrors }, null, 2)); console.error(error)
|
||||
if (settings && !settings.isDestroyed()) await shot('failure').catch(() => undefined)
|
||||
for (const server of servers) server.closeAllConnections()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ if (!process.versions.electron) {
|
||||
assert.equal(hash(await fs.readFile(assetPath)), assetSha256, 'REA robot asset changed')
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/desktop-appearance-'))
|
||||
await Promise.all(['data', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '0' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '0' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU; delete env.YCODE_DISABLE_GPU_SANDBOX
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', '--use-angle=swiftshader', '--disable-gpu-compositing', __filename],
|
||||
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Production renderer/preload/IPC -> actual OMP -> controlled localhost tools -> expert packages.
|
||||
const fs=require('node:fs/promises'),path=require('node:path'),assert=require('node:assert/strict'),crypto=require('node:crypto')
|
||||
const {pathToFileURL}=require('node:url'),pause=ms=>new Promise(resolve=>setTimeout(resolve,ms))
|
||||
const ID='flow-local-expert',CREATE='帮我创建一个 XXX 专家,擅长 XXXXX。我的经验是:[请补充你的行业背景、相关经验]',EDIT=`帮我修改专家:[${ID}],增加/优化[请补充你希望新增/优化的技能或知识领域等]方面的能力`
|
||||
const ROLE='EXPERT_FLOW_ROLE_V1: Analyse supplied facts and identify missing evidence.',UPDATED='EXPERT_FLOW_ROLE_V2: Analyse supplied facts, identify missing evidence, and verify conclusions.'
|
||||
function sourcePackage(){const p={name:ID,version:'1.0.0',description:'Controlled source-schema expert for isolated product integration.',expertType:'agent',agentName:ID,plugin:ID,agents:[`./agents/${ID}.md`],displayName:{en:'Flow Expert',zh:'验收专家'},profession:{en:'Acceptance Analyst',zh:'验收分析师'},displayDescription:{en:'Controlled analyst who follows the registered workflow.',zh:'这位分析师依据明确输入整理事实检查证据解释限制核对输出内容并在完成任务后准确说明结果与待确认事项'},defaultInitPrompt:{en:'Analyse the supplied facts.',zh:'请分析提供的事实。'},tags:[1,2,3].map(n=>({en:'Capability '+n,zh:'能力'+n})),quickPrompts:[{en:'Analyse the supplied facts.',zh:'请分析提供的事实。'},...[2,3].map(n=>({en:'Question '+n,zh:'问题'+n}))]};return{'.codebuddy-plugin/plugin.json':JSON.stringify(p,null,2),[`agents/${ID}.md`]:`---\nname: ${ID}\ndescription: Controlled local acceptance analyst\ndisplayName:\n en: Flow Expert\n zh: 验收专家\nprofession:\n en: Acceptance Analyst\n zh: 验收分析师\nmaxTurns: 50\n---\n\n${ROLE}\n`,'README.md':'# Controlled local expert\nNo remote service or generated avatar is claimed.\n'}}
|
||||
if(!process.versions.electron){
|
||||
const {spawn}=require('node:child_process')
|
||||
;(async()=>{
|
||||
const directory=await fs.mkdtemp(path.resolve('.tmp/expert-flows-'));for(const p of['data','home','sessions','workspace'])await fs.mkdir(path.join(directory,p))
|
||||
const omp=process.env.YCODE_OMP_PATH||path.join(process.env.LOCALAPPDATA,'Ygent','omp.exe');assert.ok((await fs.stat(omp)).isFile())
|
||||
const env={...process.env,YCODE_EXPERT_FLOW_DIRECTORY:directory,YCODE_USER_DATA_DIR:path.join(directory,'data'),YCODE_OMP_HOME:path.join(directory,'home'),YCODE_OMP_PATH:omp,YCODE_QA_OFFSCREEN:'1',YCODE_DISABLE_GPU:'1'}
|
||||
delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL;for(const key of Object.keys(env))if(key.startsWith('YCODE_MODEL_'))delete env[key]
|
||||
assert.equal(env.ELECTRON_RUN_AS_NODE,undefined,'Must not recursively run the Node branch inside Electron')
|
||||
for(const stage of['run','restart']){
|
||||
const code=await new Promise(resolve=>{const child=spawn(require('electron'),['--no-sandbox','--disable-gpu-sandbox',__filename],{env:{...env,YCODE_EXPERT_FLOW_STAGE:stage},windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>{output+=c;if(c.toString().includes('EXPERT_FLOW_'))process.stdout.write(c)});child.stderr.on('data',c=>output+=c);const timer=setTimeout(()=>child.kill(),180000);child.once('exit',async code=>{clearTimeout(timer);await fs.writeFile(path.join(directory,stage+'-process.log'),output);resolve(code??1)})})
|
||||
const failure=await fs.stat(path.join(directory,stage+'-failure.json')).then(()=>true,()=>false),report=await fs.readFile(path.join(directory,stage+'-report.json'),'utf8').then(JSON.parse,()=>null)
|
||||
if(code||failure||report?.result!=='PASS'||!report.checks?.length){console.log(JSON.stringify({directory,stage,result:'FAIL'}));process.exitCode=1;return}
|
||||
}
|
||||
const reports=await Promise.all(['run','restart'].map(async stage=>({stage,...JSON.parse(await fs.readFile(path.join(directory,stage+'-report.json'),'utf8'))})))
|
||||
await fs.writeFile(path.join(directory,'report.json'),JSON.stringify({result:'PASS',reports,limitations:['Controlled localhost upstream returns real OMP tool calls; no remote account or model cost.','Production UI DOM events are used, no store mutation or direct expert package IPC writes.','Whole-state pixel parity, avatars and team execution are outside this integration check.']},null,2))
|
||||
console.log(JSON.stringify({directory,result:'PASS',checks:reports.map(r=>({stage:r.stage,count:r.checks.length}))}))
|
||||
})().catch(e=>{console.error(e);process.exitCode=1})
|
||||
}else{
|
||||
const{app,BrowserWindow}=require('electron'),{createServer}=require('node:http'),directory=process.env.YCODE_EXPERT_FLOW_DIRECTORY,stage=process.env.YCODE_EXPERT_FLOW_STAGE
|
||||
assert.ok(directory&&path.relative(path.resolve('.tmp'),directory).startsWith('expert-flows-'));assert.ok(['run','restart'].includes(stage))
|
||||
app.disableHardwareAcceleration();app.setPath('userData',path.join(directory,'data'));app.setPath('home',path.join(directory,'home'));app.setPath('sessionData',path.join(directory,'sessions'))
|
||||
let main,upstream,phase='boot';const checks=[],requests=[]
|
||||
const check=(label,actual,expected=true)=>{assert.deepEqual(actual,expected,label);checks.push({label,pass:true})}
|
||||
async function read(expression){const r=await main.webContents.executeJavaScript('(async()=>{try{return{ok:true,value:await ('+expression+')}}catch(e){return{ok:false,error:String(e),stack:e.stack}}})()');if(!r.ok)throw Error(r.error+'\n'+r.stack);return r.value}
|
||||
async function wait(expression,label,timeout=30000){const start=Date.now();while(Date.now()-start<timeout){const value=await read(expression);if(value)return value;await pause(40)}throw Error('Timeout: '+label)}
|
||||
async function click(selector){if(selector.startsWith('.cr-send-button')){const registry=await read('window.ycode.models.list()');assert.ok(registry.models.every(m=>new URL(m.baseUrl).hostname==='127.0.0.1'),'QA prohibits sending with any non-localhost model profile')}await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing control '+${JSON.stringify(selector)});e.click()})()`);await pause(70)}
|
||||
async function textClick(selector,text,exact=true){await read(`(()=>{const t=${JSON.stringify(text)},e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>${exact?'e.textContent.trim()===t':'e.textContent.includes(t)'});if(!e||e.disabled)throw Error('Missing text '+t);e.click()})()`);await pause(70)}
|
||||
async function fill(selector,value){await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing editable input');Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}))})()`);await pause(50)}
|
||||
async function hoverAddMenuItem(id){const selector='.cr-add-menu__popover [data-add-menu-id="'+id+'"]';await wait('!!document.querySelector('+JSON.stringify(selector)+')','source add menu row');const point=await read('(()=>{const b=document.querySelector('+JSON.stringify(selector)+').getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');const trigger=await read('(()=>{const b=document.querySelector(".wb-composer__add-button").getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...trigger});await pause(25);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(220)}
|
||||
async function shot(name){await read('document.fonts.ready.then(()=>true)');await pause(120);await fs.writeFile(path.join(directory,stage+'-'+name+'.png'),(await main.webContents.capturePage(undefined,{stayHidden:true,stayAwake:true})).toPNG())}
|
||||
async function step(name){phase=name;await fs.writeFile(path.join(directory,stage+'-phase.txt'),name);console.log('EXPERT_FLOW_STEP '+stage+' '+name)}
|
||||
async function boot(){await import(pathToFileURL(path.resolve('out/main/index.mjs')).href);await app.whenReady();for(let i=0;i<300;i++){main=BrowserWindow.getAllWindows().find(w=>w.getTitle()==='YCode');if(main&&!main.webContents.isLoading())break;await pause(30)}assert.ok(main);main.setContentSize(1336,901);await wait('!!window.ycode&&!!document.querySelector(".wb-composer__input")','production UI');const info=await read('window.ycode.app.info()');check('isolated production configDir',path.resolve(info.configDir),path.join(directory,'data'));check('actual OMP available',info.omp.available);if(stage==='run'){const registry=await read('window.ycode.models.list()');for(const model of registry.models)await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`);const empty=await read('window.ycode.models.list()');check('real production models.remove clears isolated legacy migration',empty.models.length,0);check('no selected model remains after actual removal',empty.selectedModelId,null)}}
|
||||
function complete(res,model,content,tool){res.writeHead(200,{'content-type':'text/event-stream'});const chunk=delta=>({id:'expert-flow-response-'+requests.length,object:'chat.completion.chunk',created:1700000000,model,choices:[{index:0,delta,finish_reason:null}]});res.write('data: '+JSON.stringify(chunk({role:'assistant',...(tool?{tool_calls:[{index:0,id:'expert-flow-'+requests.length,type:'function',function:{name:tool.name,arguments:JSON.stringify(tool.args)}}]}:{content})}))+'\n\n');const final=chunk({});final.choices[0].finish_reason=tool?'tool_calls':'stop';res.write('data: '+JSON.stringify(final)+'\n\ndata: [DONE]\n\n');res.end()}
|
||||
async function startUpstream(){upstream=createServer(async(req,res)=>{try{assert.equal(req.url,'/v1/chat/completions');let raw='';for await(const c of req)raw+=c;const body=JSON.parse(raw),names=(body.tools??[]).map(t=>t.function.name),messages=body.messages??[],lastUser=messages.findLastIndex(m=>m.role==='user'),user=JSON.stringify(messages[lastUser]?.content??''),all=JSON.stringify(messages),results=messages.slice(lastUser+1).filter(m=>m.role==='tool'),mode=user.includes('帮我修改专家')?'edit':user.includes('帮我创建一个 XXX 专家')?'create':user.includes('EXPERT_FLOW')?'role':'unknown'
|
||||
const observed={route:req.url,stream:body.stream,mode,toolResults:results.length,tools:names,hasOriginalManager:all.includes('WorkBuddy 专家包管理器')&&all.includes('scripts/register_expert.py'),hasHostAdapter:all.includes('ycode-expert-manager-host'),hasRoleV1:all.includes(ROLE),hasRoleV2:all.includes(UPDATED),hasActualToolResult:results.length>0};requests.push(observed);assert.ok(requests.length<=40,'Unexpected OMP request loop')
|
||||
if(!body.stream){res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({id:'test',object:'chat.completion',choices:[{index:0,message:{role:'assistant',content:'OK'},finish_reason:'stop'}]}));return}
|
||||
if(mode==='role'){complete(res,body.model,stage==='restart'?'EXPERT_FLOW_RESTART_OK':'EXPERT_FLOW_ROLE_OK');return}
|
||||
assert.ok(['create','edit'].includes(mode),'Unknown controlled task');assert.ok(observed.hasOriginalManager&&observed.hasHostAdapter,'Manager source/adapter missing');assert.ok(names.includes('ycode_expert_package'),'Real package tool absent')
|
||||
const steps=mode==='create'?[{name:'ycode_read_skill_resource',args:{id:'builtin:expert-manager',path:'references/plugin-json-spec.md'}},{name:'ycode_read_skill_resource',args:{id:'builtin:expert-manager',path:'references/agent-md-spec.md'}},{name:'ycode_expert_package',args:{id:ID,action:'read'}},{name:'ycode_expert_package',args:{id:ID,action:'write',files:sourcePackage()}},{name:'ycode_expert_package',args:{id:ID,action:'validate'}},{name:'ycode_expert_package',args:{id:ID,action:'register'}}]:[{name:'ycode_expert_package',args:{id:ID,action:'read'}},{name:'ycode_expert_package',args:{id:ID,action:'write',files:{[`agents/${ID}.md`]:sourcePackage()[`agents/${ID}.md`].replace(ROLE,UPDATED)}}},{name:'ycode_expert_package',args:{id:ID,action:'validate'}},{name:'ycode_expert_package',args:{id:ID,action:'register'}}]
|
||||
const next=steps[results.length];observed.nextAction=next?.args.action??next?.args.path??'complete';complete(res,body.model,mode==='create'?'EXPERT_FLOW_CREATED':'EXPERT_FLOW_EDITED',next)
|
||||
}catch(error){requests.push({error:String(error)});if(!res.headersSent)res.writeHead(500);res.end('Controlled upstream failure')}});await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve))}
|
||||
async function configure(retrySessionId){const profile={id:'expert-flow-model',name:'Expert Flow Fixture',providerId:'custom',model:'expert-flow-fixture',baseUrl:`http://127.0.0.1:${upstream.address().port}/v1`,apiKey:'',authMode:'none',supportsToolCall:true,supportsImages:false,maxInputTokens:64000,maxOutputTokens:4000,maxToolRounds:20};await read(`window.ycode.models.save(${JSON.stringify(profile)},${stage==='restart'})`);await read('window.ycode.models.select("expert-flow-model")');if(stage==='run')await read(`window.ycode.workspace.attach(${JSON.stringify(path.join(directory,'workspace'))})`);if(retrySessionId){await read(`window.ycode.session.updateContext(${JSON.stringify(retrySessionId)},{modelId:'expert-flow-model'})`);check('actual failed session model context configured',(await read('window.ycode.session.list()')).find(s=>s.id===retrySessionId).modelId,'expert-flow-model')}else{main.webContents.reload();await pause(400);await wait('!!document.querySelector(".wb-composer__input")','profile UI reload')}}
|
||||
async function centerMine(){await textClick('.sidebar__tab','专家·技能·连接器');await wait('!!document.querySelector(".yc-capability-center")','center');if(!(await read('!!document.querySelector("[data-expert-page=mine]")'))){if(await read('[...document.querySelectorAll(".um-tab")].some(e=>e.textContent.trim()==="专家")'))await textClick('.um-tab','专家');await textClick('button','我的专家')}await wait('!!document.querySelector("[data-expert-page=mine]")','source mine page')}
|
||||
async function terminal(id,expected){const start=Date.now();let approval=0;while(Date.now()-start<90000){if(await read('[...document.querySelectorAll("button")].some(e=>!e.disabled&&e.textContent.includes("允许一次"))')){await shot('approval-'+(++approval));await textClick('button','允许一次',false)}const messages=await read(`window.ycode.session.messages(${JSON.stringify(id)})`),assistant=messages.findLast(m=>m.role==='assistant');if(assistant&&assistant.status!=='streaming'){check('terminal actual OMP '+expected,assistant.status,'done');check('actual model result '+expected,assistant.content,expected);return{assistant,messages}}await pause(80)}throw Error('Timeout actual OMP '+expected)}
|
||||
async function sendNew(expected){const ids=(await read('window.ycode.session.list()')).map(s=>s.id);await click('.cr-send-button:not(.cr-send-button--stop)');const created=await wait(`window.ycode.session.list().then(s=>s.find(x=>!${JSON.stringify(ids)}.includes(x.id)))`,'new UI-created session');return{session:created,...await terminal(created.id,expected)}}
|
||||
async function summon(expectNew=true){await wait(`!!document.querySelector('.ec-cr-card')&&[...document.querySelectorAll('.ec-cr-card')].some(e=>e.textContent.includes('验收分析师'))`,'real source CreatedExpertsPanel card');const point=await read(`(()=>{const r=document.querySelector('.ec-cr-card').getBoundingClientRect();return{x:Math.round(r.right-40),y:Math.round(r.top+30)}})()`);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200);await wait(`(()=>{const e=[...document.querySelectorAll('.ec-cr-card button')].find(e=>e.textContent.trim()==='召唤');if(!e||e.disabled)return false;for(let n=e;n&&n!==document.body;n=n.parentElement){const s=getComputedStyle(n);if(s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0)return false}return e.getBoundingClientRect().width>0})()`,'source summon action visible after native hover');check('source summon action revealed by native hover',true);await shot('my-experts-card');await textClick('.ec-cr-card button','召唤');await wait(`!!document.querySelector('[data-active-expert="${ID}"]')`,'expert bound to new draft');if(expectNew)check('summon selects unsent draft',await read('!document.querySelector(".sidebar__item[data-selected=true]")'));await shot('summoned-draft')}
|
||||
async function cleanup(){if(upstream){upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve))}}
|
||||
;(async()=>{
|
||||
await boot();await startUpstream();if(stage==='restart')await configure();const buildSha256=crypto.createHash('sha256').update(await fs.readFile('out/main/index.mjs')).digest('hex')
|
||||
if(stage==='restart'){
|
||||
const prior=JSON.parse(await fs.readFile(path.join(directory,'run-report.json'),'utf8'));await step('restart-history');const experts=await read('window.ycode.experts.list()');check('package discovered after real app restart',experts.some(e=>e.id===ID&&!e.error));const history=await read(`window.ycode.session.messages(${JSON.stringify(prior.roleSessionId)})`);check('prior assistant identity persists',history.findLast(m=>m.role==='assistant').assistantDisplay?.id,ID);check('prior role reply persists',history.findLast(m=>m.role==='assistant').content,'EXPERT_FLOW_ROLE_OK')
|
||||
await click(`[data-session-id="${prior.roleSessionId}"]`);await wait('[...document.querySelectorAll(".cr-agent__name")].some(e=>e.textContent==="验收分析师")','restored source chat identity');await shot('history-identity')
|
||||
await centerMine();await summon();await fill('.wb-composer__input','EXPERT_FLOW_RESTART_VERIFY');const role=await sendNew('EXPERT_FLOW_RESTART_OK');check('updated role reaches actual upstream after restart',requests.some(r=>r.mode==='role'&&r.hasRoleV2));check('new reply persists correct identity',role.assistant.assistantDisplay?.id,ID);await shot('new-reply-identity');await fs.writeFile(path.join(directory,stage+'-report.json'),JSON.stringify({result:'PASS',checks,requests,buildSha256,sessionId:role.session.id},null,2))
|
||||
}else{
|
||||
await step('source-create-entry');await centerMine();await shot('mine-empty');await textClick('button','创建专家');await wait(`document.querySelector('.wb-composer__input')?.value===${JSON.stringify(CREATE)}`,'original create prompt after owner commit');check('original create prompt',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('original manager structured skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));await shot('create-source-draft')
|
||||
await step('actual-no-model-preflight');check('controlled model remains unconfigured before first send',(await read('window.ycode.models.list()')).models.some(m=>m.id==='expert-flow-model'),false);const beforeFailure=(await read('window.ycode.session.list()')).map(s=>s.id);await click('.cr-send-button:not(.cr-send-button--stop)');const failedSession=await wait(`window.ycode.session.list().then(s=>s.find(x=>!${JSON.stringify(beforeFailure)}.includes(x.id)))`,'real session:create before failed chat:send');await wait(`document.querySelector('[role=alert]')?.textContent.includes('请先添加并选择自定义模型')`,'real missing-model preflight rejection');check('failed session becomes actual visible owner',await read(`document.querySelector('.sidebar__item[data-selected=true]')?.dataset.sessionId`),failedSession.id);check('preflight failure retains exact create text',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('preflight failure retains manager chip for new session owner',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));check('preflight stores zero conversation messages',(await read(`window.ycode.session.messages(${JSON.stringify(failedSession.id)})`)).length,0);check('preflight makes no upstream model request',requests.length,0);await shot('no-model-preflight-retained')
|
||||
await configure(failedSession.id);check('model setup keeps same owner draft text',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('model setup keeps same owner skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'))
|
||||
await step('actual-omp-create');await click('.cr-send-button:not(.cr-send-button--stop)');const creation={session:failedSession,...await terminal(failedSession.id,'EXPERT_FLOW_CREATED')};check('successful retry reuses actual failed session',(await read('window.ycode.session.list()')).length,beforeFailure.length+1);const creationTools=creation.assistant.tools.filter(t=>t.name==='ycode_expert_package');check('actual OMP package actions order',creationTools.map(t=>t.args.action),['read','write','validate','register']);check('create skill URI persisted',creation.messages[0].skills[0].uri,'skill://expert-manager');check('original full manager reached actual upstream',requests.some(r=>r.mode==='create'&&r.hasOriginalManager&&r.hasHostAdapter));check('actual register tool returned success',creationTools.at(-1).output.includes('"success":true'));await shot('created-chat')
|
||||
const root=path.join(directory,'data','plugins','marketplaces','my-experts'),market=JSON.parse(await fs.readFile(path.join(root,'.codebuddy-plugin','marketplace.json'),'utf8'));check('actual marketplace package registered',market.plugins.some(p=>p.name===ID&&p.source===`./plugins/${ID}`));check('actual package session marker',await fs.readFile(path.join(root,'plugins',ID,'.created-by-session'),'utf8'),creation.session.id)
|
||||
await step('source-card-summon');await centerMine();await summon();await fill('.wb-composer__input','EXPERT_FLOW_ROLE_VERIFY');const role=await sendNew('EXPERT_FLOW_ROLE_OK');check('real session expert binding',role.session.expertId,ID);check('expert role enters real OMP upstream',requests.some(r=>r.mode==='role'&&r.hasRoleV1));check('actual assistant identity',role.assistant.assistantDisplay?.id,ID);await wait('[...document.querySelectorAll(".cr-agent__name")].some(e=>e.textContent==="验收分析师")','source assistant header');await shot('role-chat')
|
||||
await step('source-plus-menu-interactions');await fs.writeFile(path.join(directory,'workspace','reference-notes.md'),'# Controlled reference fixture');await click('.wb-composer__add-button');await wait('!!document.querySelector("[data-add-menu-id=reference-files]")','real workspace references loaded');check('source plus trigger expands actual parent',await read('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'),'true');check('source plus parent width',await read('Math.round(document.querySelector(".cr-add-menu__popover").getBoundingClientRect().width)'),200);check('source plus menu row height',await read('Math.round(document.querySelector(".cr-add-menu__popover [data-add-menu-id=experts]").getBoundingClientRect().height)'),32);await hoverAddMenuItem('add-files');await wait('!!document.querySelector(".cr-files-submenu")','native files submenu');check('files hover keeps parent open',await read('!!document.querySelector(".cr-add-menu__popover")'));await hoverAddMenuItem('mode');await wait('document.querySelectorAll(".cr-mode-submenu .wb-switch__input").length===2','source mode switches');await click('.cr-mode-submenu .wb-switch__input');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.agentMode==='plan')`,'native mode persists plan');check('Plan clears expert binding',(await read(`window.ycode.session.list()`)).find(s=>s.id===role.session.id).expertId,null);check('mode toggle keeps parent open',await read('!!document.querySelector(".cr-add-menu__popover")'));await click('.cr-mode-submenu .wb-switch__input');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.agentMode==='craft')`,'source same Plan toggle restores craft');await hoverAddMenuItem('skills');await wait('!!document.querySelector(".yc-skill-picker[data-embedded] input")','embedded skill submenu');check('skill submenu mounted inside source overlay',await read('!!document.querySelector(".cr-sub-menu__overlay .yc-skill-picker")'));await fill('.yc-skill-picker input','NO_MATCH');main.webContents.sendInputEvent({type:'keyDown',keyCode:'ESC'});main.webContents.sendInputEvent({type:'keyUp',keyCode:'ESC'});await wait('document.querySelector(".yc-skill-picker input")?.value===""','Escape clears search without dismissing parent');check('nonempty search Escape retains parent',await read('!!document.querySelector(".cr-add-menu__popover")'));await hoverAddMenuItem('connectors');await wait('!!document.querySelector(".cr-connectors-submenu")','source connector submenu');check('connector menu includes real manage action',await read('!!document.querySelector(".cr-connectors-submenu__footer")'));await shot('source-plus-connectors');await hoverAddMenuItem('reference-files');await wait('!!document.querySelector(".cr-reference-files-submenu__item")','actual reference choices');await textClick('.cr-reference-files-submenu__item','reference-notes.md');await wait('!!document.querySelector(".wb-composer__resources")','structured workspace reference');check('reference selection closes whole parent',await read('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'),'false');check('actual reference filename present',await read('document.querySelector(".wb-composer__resources").textContent.includes("reference-notes.md")'));await click('.wb-composer__resources button');await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await click('.yc-expert-picker [data-expert-id=flow-local-expert]');await wait('!!document.querySelector("[data-active-expert]")&&!document.querySelector(".yc-expert-picker")','native source picker restores expert');check('expert restore resumes actual craft',(await read('window.ycode.session.list()')).find(s=>s.id===role.session.id).agentMode,'craft');await fill('.wb-composer__input','');
|
||||
await step('expert-usage-existing-conversation');const countBeforeReturn=(await read('window.ycode.session.list()')).length;await fill('.wb-composer__input','USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await wait('!!document.querySelector(".yc-expert-picker [data-expert-manage]")','expert more action');await click('.yc-expert-picker [data-expert-manage]');await wait('!!document.querySelector("[data-expert-page=mine]")','return-aware expert center');await summon(false);check('expert center return retains original conversation',await read('document.querySelector(".sidebar__item[data-selected=true]")?.dataset.sessionId'),role.session.id);check('expert center return preserves typed draft',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');check('expert summon does not create empty sessions',(await read('window.ycode.session.list()')).length,countBeforeReturn);
|
||||
async function removeExpertChip(){const point=await read('(()=>{const e=document.querySelector("[data-active-expert]"),b=e.getBoundingClientRect();return{x:Math.round(b.x+8),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(160);await click('[data-active-expert] button');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.expertId===null)`,'expert removed through source chip')}
|
||||
async function pickExistingExpert(){await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await wait('!!document.querySelector(".yc-expert-picker [data-expert-id=flow-local-expert]")','expert row');await click('.yc-expert-picker [data-expert-id=flow-local-expert]');await wait('!!document.querySelector("[data-active-expert]")&&!document.querySelector(".yc-expert-picker")','expert picker activation')}
|
||||
await removeExpertChip();check('removing expert preserves user-authored text',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');await pickExistingExpert();check('picker activation preserves user-authored text',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');check('switching expert never changes prior assistant snapshot',(await read(`window.ycode.session.messages(${JSON.stringify(role.session.id)})`)).findLast(m=>m.role==='assistant').assistantDisplay.id,ID);await fill('.wb-composer__input','');await removeExpertChip();await pickExistingExpert();check('empty conversation draft receives default input',await read('document.querySelector(".wb-composer__input").value'),'请分析提供的事实。');await removeExpertChip();check('conversation clear removes untouched expert prefill',await read('document.querySelector(".wb-composer__input").value'),'');await shot('expert-usage-cleared');check('expert interaction sends no extra user message',(await read(`window.ycode.session.messages(${JSON.stringify(role.session.id)})`)).filter(m=>m.role==='user').length,1);
|
||||
await step('source-edit-entry');await centerMine();await click('.ec-cr-more-btn');await wait('[...document.querySelectorAll(\'[role="menuitem"]\')].some(e=>e.textContent.includes("修改专家"))','source card edit menu');await shot('source-edit-menu');await textClick('[role="menuitem"]','修改专家');await wait(`document.querySelector('.wb-composer__input')?.value===${JSON.stringify(EDIT)}`,'original edit prompt after owner commit');check('original edit prompt',await read('document.querySelector(".wb-composer__input").value'),EDIT);check('edit manager structured skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));check('editing starts manager draft without active expert',await read('!document.querySelector("[data-active-expert]")'));await shot('edit-source-draft')
|
||||
await step('actual-omp-edit');const edit=await sendNew('EXPERT_FLOW_EDITED');check('actual edit package action order',edit.assistant.tools.filter(t=>t.name==='ycode_expert_package').map(t=>t.args.action),['read','write','validate','register']);check('updated Agent actually written',(await fs.readFile(path.join(root,'plugins',ID,'agents',ID+'.md'),'utf8')).includes(UPDATED));check('edit marker tied to actual edit session',await fs.readFile(path.join(root,'plugins',ID,'.created-by-session'),'utf8'),edit.session.id);check('edit skill URI persists',edit.messages[0].skills[0].uri,'skill://expert-manager');await shot('edited-chat');await centerMine();await wait('!!document.querySelector(".ec-cr-card")','edited source card');await shot('edited-source-card')
|
||||
await fs.writeFile(path.join(directory,stage+'-report.json'),JSON.stringify({result:'PASS',checks,requests,buildSha256,createSessionId:creation.session.id,roleSessionId:role.session.id,editSessionId:edit.session.id},null,2))
|
||||
}
|
||||
console.log('EXPERT_FLOW_PASS '+stage+' '+directory);await cleanup();app.quit()
|
||||
})().catch(async error=>{await fs.writeFile(path.join(directory,stage+'-failure.json'),JSON.stringify({result:'FAIL',phase,error:String(error),stack:error.stack,checks,requests},null,2)).catch(()=>{});if(main&&!main.isDestroyed())await shot('failure').catch(()=>{});console.log('EXPERT_FLOW_FAIL '+phase+' '+String(error));await cleanup().catch(()=>{});process.exitCode=1;app.quit()})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ if (!process.versions.electron) {
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
await fs.mkdir(nextWorkspace, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user